/** * Mandu Guard Validator * * 아키텍처 규칙 검증 */ import type { FileAnalysis, ImportInfo, LayerDefinition, Violation, ViolationType, Severity, SeverityConfig, GuardConfig, } from "./types"; import { basename, dirname, isAbsolute, relative, resolve } from "path"; import { resolveImportLayer, shouldIgnoreImport } from "./analyzer"; import { generateSmartSuggestions } from "./suggestions"; import { FILE_PATTERNS } from "../router/fs-types"; // ═══════════════════════════════════════════════════════════════════════════ // Layer Validation // ═══════════════════════════════════════════════════════════════════════════ /** * 레이어 의존성 검증 */ export function validateLayerDependency( fromLayer: string, toLayer: string, layers: LayerDefinition[] ): boolean { const fromLayerDef = layers.find((l) => l.name === fromLayer); if (!fromLayerDef) return true; // 알 수 없는 레이어는 통과 return fromLayerDef.canImport.includes(toLayer); } /** * 같은 레이어 내 같은 슬라이스인지 확인 */ export function isSameSlice( fromSlice: string | undefined, toSlice: string | undefined ): boolean { if (!fromSlice || !toSlice) return false; return fromSlice === toSlice; } // ═══════════════════════════════════════════════════════════════════════════ // Violation Detection // ═══════════════════════════════════════════════════════════════════════════ /** * 위반 생성 */ export function createViolation( type: ViolationType, analysis: FileAnalysis, importInfo: ImportInfo, fromLayer: string, toLayer: string, layers: LayerDefinition[], severityConfig: SeverityConfig ): Violation { const fromLayerDef = layers.find((l) => l.name === fromLayer); const allowedLayers = fromLayerDef?.canImport ?? []; const severityMap: Record = { "layer-violation": "layerViolation", "circular-dependency": "circularDependency", "cross-slice": "crossSliceDependency", "deep-nesting": "deepNesting", "file-type": "fileType", "invalid-shared-segment": "invalidSharedSegment", }; const severity: Severity = severityConfig[severityMap[type]] ?? "error"; const ruleNames: Record = { "layer-violation": "Layer Dependency", "circular-dependency": "Circular Dependency", "cross-slice": "Cross-Slice Dependency", "deep-nesting": "Deep Nesting", "file-type": "TypeScript Only", "invalid-shared-segment": "Shared Segment", }; const ruleDescriptions: Record = { "layer-violation": `"${fromLayer}" 레이어는 "${toLayer}" 레이어를 import할 수 없습니다`, "circular-dependency": `순환 의존성이 감지되었습니다: ${fromLayer} ⇄ ${toLayer}`, "cross-slice": `같은 레이어 내 다른 슬라이스 간 직접 import가 감지되었습니다`, "deep-nesting": `깊은 경로 import가 감지되었습니다. Public API를 통해 import하세요`, "file-type": `JS/JSX 파일은 금지됩니다. .ts/.tsx로 변환하세요`, "invalid-shared-segment": `shared 하위 세그먼트 규칙을 위반했습니다`, }; // 스마트 제안 생성 const suggestions = generateSmartSuggestions({ type, fromLayer, toLayer, importPath: importInfo.path, allowedLayers, layers, slice: analysis.slice, }); return { type, filePath: analysis.filePath, line: importInfo.line, column: importInfo.column, importStatement: importInfo.statement, importPath: importInfo.path, fromLayer, toLayer, ruleName: ruleNames[type], ruleDescription: ruleDescriptions[type], severity, allowedLayers, suggestions, }; } function createFileTypeViolation( analysis: FileAnalysis, severityConfig: SeverityConfig ): Violation { const severity: Severity = severityConfig.fileType ?? "error"; const normalizedPath = analysis.filePath.replace(/\\/g, "/"); const extension = normalizedPath.slice(normalizedPath.lastIndexOf(".")); return { type: "file-type", filePath: analysis.filePath, line: 1, column: 1, importStatement: normalizedPath, importPath: normalizedPath, fromLayer: "typescript", toLayer: extension, ruleName: "TypeScript Only", ruleDescription: `JS/JSX 파일은 금지됩니다 (${extension}). .ts/.tsx로 변환하세요`, severity, allowedLayers: [], suggestions: [ "파일 확장자를 .ts 또는 .tsx로 변경하세요", "필요한 타입을 추가하고 TypeScript로 변환하세요", ], }; } function createInvalidSharedSegmentViolation( analysis: FileAnalysis, severityConfig: SeverityConfig ): Violation { const severity: Severity = severityConfig.invalidSharedSegment ?? "error"; const normalizedPath = analysis.filePath.replace(/\\/g, "/"); const marker = "src/shared/"; let segment = "(unknown)"; const index = normalizedPath.indexOf(marker); if (index !== -1) { const rest = normalizedPath.slice(index + marker.length); segment = rest.split("/")[0] || "(root)"; } return { type: "invalid-shared-segment", filePath: analysis.filePath, line: 1, column: 1, importStatement: normalizedPath, importPath: normalizedPath, fromLayer: "shared", toLayer: "shared/unsafe", ruleName: "Shared Segment", ruleDescription: `src/shared/${segment}는 허용되지 않습니다`, severity, allowedLayers: [], suggestions: [ "허용 경로: src/shared/{contracts,schema,types,utils/client,utils/server,env}/ 또는 src/shared/{contracts,schema,types,env}.ts", "파일을 허용된 shared 하위 폴더로 이동하거나, 허용된 이름의 직접 파일로 배치하세요", ], }; } function createSharedEnvImportViolation( analysis: FileAnalysis, importInfo: ImportInfo, fromLayer: string, allowedLayers: string[], severityConfig: SeverityConfig ): Violation { const severity: Severity = severityConfig.layerViolation ?? "error"; return { type: "layer-violation", filePath: analysis.filePath, line: importInfo.line, column: importInfo.column, importStatement: importInfo.statement, importPath: importInfo.path, fromLayer, toLayer: "shared/env", ruleName: "Shared Env (Server-only)", ruleDescription: "shared/env는 서버 전용입니다. 클라이언트 레이어에서 import할 수 없습니다", severity, allowedLayers, suggestions: [ "환경변수 접근은 src/server 또는 app/api/route.ts에서 처리하세요", "필요한 값은 서버에서 주입하거나 응답으로 전달하세요", ], }; } // ═══════════════════════════════════════════════════════════════════════════ // File Validation // ═══════════════════════════════════════════════════════════════════════════ /** * 파일 분석 결과 검증 */ export function validateFileAnalysis( analysis: FileAnalysis, layers: LayerDefinition[], config: GuardConfig ): Violation[] { const violations: Violation[] = []; const severityConfig = config.severity ?? {}; const normalizedPath = analysis.filePath.toLowerCase(); if (normalizedPath.endsWith(".js") || normalizedPath.endsWith(".jsx")) { violations.push(createFileTypeViolation(analysis, severityConfig)); } if (analysis.layer === "shared/unsafe") { violations.push(createInvalidSharedSegmentViolation(analysis, severityConfig)); } // FS Routes 규칙 검사 (app/ 내부) violations.push(...validateFsRoutesImports(analysis, layers, config)); // 파일이 레이어에 속하지 않으면 검사 안 함 if (!analysis.layer) { return violations; } const fromLayer = analysis.layer; const fromLayerDef = layers.find((l) => l.name === fromLayer); if (!fromLayerDef) { return violations; } for (const importInfo of analysis.imports) { const isFsRoutesImport = isFsRoutesImportPath(importInfo.path); if (shouldIgnoreImport(importInfo.path, config) && !isFsRoutesImport) { continue; } // Import 레이어 해석 const toLayer = resolveImportLayer( importInfo.path, layers, config.srcDir ?? "src", analysis.filePath, analysis.rootDir ); if (!toLayer) { continue; // 레이어를 알 수 없으면 무시 } if (toLayer === "shared/env" && fromLayer.startsWith("client/")) { violations.push( createSharedEnvImportViolation( analysis, importInfo, fromLayer, fromLayerDef?.canImport ?? [], severityConfig ) ); continue; } // 같은 레이어 내 같은 슬라이스는 허용 if (fromLayer === toLayer) { // Cross-slice 체크 (같은 레이어 내 다른 슬라이스) const toSlice = extractSliceFromImport( importInfo.path, toLayer, config.srcDir ?? "src", analysis.filePath, analysis.rootDir ); if (analysis.slice && toSlice && analysis.slice !== toSlice) { violations.push( createViolation( "cross-slice", analysis, importInfo, fromLayer, toLayer, layers, severityConfig ) ); } continue; } // 레이어 의존성 검증 if (!validateLayerDependency(fromLayer, toLayer, layers)) { violations.push( createViolation( "layer-violation", analysis, importInfo, fromLayer, toLayer, layers, severityConfig ) ); } } return violations; } /** * Import 경로에서 슬라이스 추출 */ function extractSliceFromImport( importPath: string, layer: string, srcDir: string, fromFile?: string, rootDir?: string ): string | undefined { const normalizedImportPath = importPath.replace(/\\/g, "/"); const normalizedSrcDir = srcDir.replace(/\\/g, "/").replace(/\/$/, ""); let layerRelative: string | undefined; if (normalizedImportPath.startsWith("@/") || normalizedImportPath.startsWith("~/")) { layerRelative = normalizedImportPath.slice(2); } else if ( normalizedSrcDir.length > 0 && normalizedSrcDir !== "." && (normalizedImportPath === normalizedSrcDir || normalizedImportPath.startsWith(`${normalizedSrcDir}/`)) ) { layerRelative = normalizedImportPath.startsWith(`${normalizedSrcDir}/`) ? normalizedImportPath.slice(normalizedSrcDir.length + 1) : normalizedImportPath; } else if (normalizedImportPath.startsWith(".")) { if (!fromFile || !rootDir) { return undefined; } const absoluteFromFile = isAbsolute(fromFile) ? fromFile : resolve(rootDir, fromFile); const resolvedPath = resolve(dirname(absoluteFromFile), normalizedImportPath); const relativeToRoot = relative(rootDir, resolvedPath).replace(/\\/g, "/"); if (relativeToRoot.startsWith("..") || relativeToRoot.startsWith("../")) { return undefined; } layerRelative = normalizedSrcDir.length > 0 && normalizedSrcDir !== "." && relativeToRoot.startsWith(`${normalizedSrcDir}/`) ? relativeToRoot.slice(normalizedSrcDir.length + 1) : relativeToRoot; } if (!layerRelative) return undefined; const parts = layerRelative.split("/"); const layerParts = layer.split("/"); const matchesLayer = parts.slice(0, layerParts.length).join("/") === layer; if (matchesLayer && parts.length > layerParts.length) { return parts[layerParts.length]; } return undefined; } /** * FS Routes 규칙 검증 (app/ 내부) */ function validateFsRoutesImports( analysis: FileAnalysis, layers: LayerDefinition[], config: GuardConfig ): Violation[] { const fsRoutesConfig = config.fsRoutes; if (!fsRoutesConfig) return []; const fileType = getFsRouteFileType(analysis); if (!fileType) return []; const violations: Violation[] = []; const severity = config.severity?.layerViolation ?? "error"; const allowedLayers = fileType === "page" ? fsRoutesConfig.pageCanImport : fileType === "layout" ? fsRoutesConfig.layoutCanImport : fsRoutesConfig.routeCanImport; for (const importInfo of analysis.imports) { const isFsRoutesImport = isFsRoutesImportPath(importInfo.path); if (shouldIgnoreImport(importInfo.path, config) && !isFsRoutesImport) { continue; } // Rule: page -> page 금지 if (fileType === "page" && fsRoutesConfig.noPageToPage) { if (resolvesToPageImport(importInfo.path, analysis)) { violations.push({ type: "layer-violation", filePath: analysis.filePath, line: importInfo.line, column: importInfo.column, importStatement: importInfo.statement, importPath: importInfo.path, fromLayer: "page", toLayer: "page", ruleName: "FS Routes Page Import", ruleDescription: "page.tsx에서 다른 page.tsx import는 금지됩니다", severity, allowedLayers: allowedLayers ?? [], suggestions: [ "공통 UI는 app/ 외부(shared/widgets)로 이동하세요", "필요한 데이터는 상위 layout에서 주입하세요", ], }); } } // Rule: page/layout import 가능한 레이어 제한 if (allowedLayers) { const toLayer = resolveImportLayer( importInfo.path, layers, config.srcDir ?? "src", analysis.filePath, analysis.rootDir ); if (toLayer === "shared/env" && fileType !== "route") { violations.push( createSharedEnvImportViolation( analysis, importInfo, fileType, allowedLayers, config.severity ?? {} ) ); continue; } if (toLayer && !allowedLayers.includes(toLayer)) { const fileLabel = fileType === "route" ? "route.ts" : `${fileType}.tsx`; const suggestions = fileType === "route" ? [ `허용 레이어: ${allowedLayers.join(", ")}`, "서버 로직은 src/server 또는 src/shared로 이동하세요", ] : [ `허용 레이어: ${allowedLayers.join(", ")}`, "클라이언트 로직은 src/client 또는 src/shared로 이동하세요", ]; violations.push({ type: "layer-violation", filePath: analysis.filePath, line: importInfo.line, column: importInfo.column, importStatement: importInfo.statement, importPath: importInfo.path, fromLayer: fileType, toLayer, ruleName: "FS Routes Import Rule", ruleDescription: `${fileLabel}는 지정된 레이어만 import 가능합니다`, severity, allowedLayers, suggestions, }); } } } return violations; } function getFsRouteFileType(analysis: FileAnalysis): "page" | "layout" | "route" | null { const normalizedPath = normalizePathValue( analysis.rootDir ? relative( analysis.rootDir, isAbsolute(analysis.filePath) ? analysis.filePath : resolve(analysis.rootDir, analysis.filePath) ) : analysis.filePath ); if (!isFsRoutesPath(normalizedPath)) { return null; } const fileName = basename(normalizedPath); if (FILE_PATTERNS.page.test(fileName)) { return "page"; } if (FILE_PATTERNS.layout.test(fileName)) { return "layout"; } if (FILE_PATTERNS.route.test(fileName)) { return "route"; } return null; } function isFsRoutesPath(normalizedPath: string): boolean { const cleaned = normalizedPath.replace(/^\.\/+/, ""); const segments = cleaned.split("/"); if (segments.includes("src")) { return false; } return segments.includes("app"); } function isFsRoutesImportPath(importPath: string): boolean { const normalized = importPath.replace(/\\/g, "/").replace(/^\.\/+/, ""); const segments = normalized.split("/"); if (segments.includes("src")) { return false; } return segments.includes("app"); } function resolvesToPageImport(importPath: string, analysis: FileAnalysis): boolean { const normalizedImport = importPath.replace(/\\/g, "/"); const importFileName = basename(normalizedImport); if (FILE_PATTERNS.page.test(importFileName) || importFileName === "page") { if (!analysis.rootDir) { return isFsRoutesPath(normalizedImport); } } if (normalizedImport.startsWith(".")) { const resolvedPath = resolveFsRoutesPath(normalizedImport, analysis); if (!resolvedPath) return false; const resolvedFileName = basename(resolvedPath); return ( isFsRoutesPath(resolvedPath) && (FILE_PATTERNS.page.test(resolvedFileName) || resolvedFileName === "page") ); } if (normalizedImport.startsWith("app/") || normalizedImport.includes("/app/")) { return FILE_PATTERNS.page.test(importFileName) || importFileName === "page"; } if (normalizedImport.startsWith("@/") || normalizedImport.startsWith("~/")) { const aliasPath = normalizedImport.slice(2); const aliasFileName = basename(aliasPath); return ( isFsRoutesPath(aliasPath) && (FILE_PATTERNS.page.test(aliasFileName) || aliasFileName === "page") ); } return false; } function resolveFsRoutesPath(importPath: string, analysis: FileAnalysis): string | null { if (!analysis.rootDir) return null; const absoluteFromFile = isAbsolute(analysis.filePath) ? analysis.filePath : resolve(analysis.rootDir, analysis.filePath); const resolvedPath = resolve(dirname(absoluteFromFile), importPath); const relativePath = normalizePathValue(relative(analysis.rootDir, resolvedPath)); if (relativePath.startsWith("..")) { return null; } return relativePath; } // ═══════════════════════════════════════════════════════════════════════════ // Batch Validation // ═══════════════════════════════════════════════════════════════════════════ /** * 여러 파일 분석 결과 검증 */ export function validateAnalyses( analyses: FileAnalysis[], layers: LayerDefinition[], config: GuardConfig ): Violation[] { const allViolations: Violation[] = []; for (const analysis of analyses) { const violations = validateFileAnalysis(analysis, layers, config); allViolations.push(...violations); } return allViolations; } /** * 순환 의존성 감지 */ export function detectCircularDependencies( analyses: FileAnalysis[], layers: LayerDefinition[], config: GuardConfig ): Violation[] { const violations: Violation[] = []; const lookup = buildFileLookup(analyses); const graph = buildDependencyGraph(analyses, config, lookup); const analysisByPath = new Map(); const seenPairs = new Set(); for (const analysis of analyses) { analysisByPath.set(normalizePathValue(analysis.filePath), analysis); } // 간단한 직접 순환 감지 (A → B → A) for (const [file, deps] of graph.entries()) { for (const dep of deps) { const depDeps = graph.get(dep); if (depDeps?.includes(file)) { const pairKey = [file, dep].sort().join("::"); if (seenPairs.has(pairKey)) continue; seenPairs.add(pairKey); const analysis = analysisByPath.get(file); if (!analysis) continue; const importInfo = analysis.imports.find( (i) => resolveImportTarget(i.path, analysis, config, lookup) === dep ); if (!importInfo) continue; const depAnalysis = analysisByPath.get(dep); const fromLayer = analysis.layer ?? "unknown"; const toLayer = depAnalysis?.layer ?? resolveImportLayer( importInfo.path, layers, config.srcDir ?? "src", analysis.filePath, analysis.rootDir ) ?? "unknown"; violations.push( createViolation( "circular-dependency", analysis, importInfo, fromLayer, toLayer, layers, config.severity ?? {} ) ); } } } return violations; } /** * 의존성 그래프 빌드 */ function buildDependencyGraph( analyses: FileAnalysis[], config: GuardConfig, lookup: Map ): Map { const graph = new Map(); for (const analysis of analyses) { const deps = new Set(); const fromPath = normalizePathValue(analysis.filePath); for (const imp of analysis.imports) { if (shouldIgnoreImport(imp.path, config)) { continue; } const resolved = resolveImportTarget(imp.path, analysis, config, lookup); if (resolved && resolved !== fromPath) { deps.add(resolved); } } graph.set(fromPath, Array.from(deps)); } return graph; } function normalizePathValue(filePath: string): string { return filePath.replace(/\\/g, "/"); } function stripExtension(filePath: string): string { return filePath.replace(/\.[^/.]+$/, ""); } function expandPathKeys(filePath: string): string[] { const normalized = normalizePathValue(filePath); const noExt = stripExtension(normalized); const keys = new Set([normalized, noExt]); if (noExt.endsWith("/index")) { keys.add(noExt.slice(0, -"/index".length)); } if (normalized.endsWith("/index")) { keys.add(normalized.slice(0, -"/index".length)); } return Array.from(keys); } function buildFileLookup(analyses: FileAnalysis[]): Map { const lookup = new Map(); for (const analysis of analyses) { const canonical = normalizePathValue(analysis.filePath); for (const key of expandPathKeys(canonical)) { if (!lookup.has(key)) { lookup.set(key, canonical); } } if (analysis.rootDir) { const absolutePath = isAbsolute(analysis.filePath) ? analysis.filePath : resolve(analysis.rootDir, analysis.filePath); const absoluteNormalized = normalizePathValue(absolutePath); for (const key of expandPathKeys(absoluteNormalized)) { if (!lookup.has(key)) { lookup.set(key, canonical); } } const relativePath = normalizePathValue(relative(analysis.rootDir, absolutePath)); for (const key of expandPathKeys(relativePath)) { if (!lookup.has(key)) { lookup.set(key, canonical); } } } } return lookup; } function resolveImportTarget( importPath: string, analysis: FileAnalysis, config: GuardConfig, lookup: Map ): string | null { const normalizedImportPath = importPath.replace(/\\/g, "/"); const srcDir = (config.srcDir ?? "src").replace(/\\/g, "/").replace(/\/$/, ""); const candidates: string[] = []; if (normalizedImportPath.startsWith("@/") || normalizedImportPath.startsWith("~/")) { const aliasPath = normalizedImportPath.slice(2); const withSrc = srcDir.length > 0 ? `${srcDir}/${aliasPath}` : aliasPath; candidates.push(withSrc, aliasPath); if (analysis.rootDir) { candidates.push(normalizePathValue(resolve(analysis.rootDir, withSrc))); } } else if (normalizedImportPath.startsWith(".")) { if (!analysis.rootDir) return null; const absoluteFromFile = isAbsolute(analysis.filePath) ? analysis.filePath : resolve(analysis.rootDir, analysis.filePath); const resolvedPath = resolve(dirname(absoluteFromFile), normalizedImportPath); const normalizedResolved = normalizePathValue(resolvedPath); const relativeToRoot = normalizePathValue(relative(analysis.rootDir, resolvedPath)); candidates.push(normalizedResolved); if (!relativeToRoot.startsWith("..")) { candidates.push(relativeToRoot); if (srcDir.length > 0 && srcDir !== "." && relativeToRoot.startsWith(`${srcDir}/`)) { candidates.push(relativeToRoot.slice(srcDir.length + 1)); } } } else if (srcDir.length > 0 && srcDir !== "." && (normalizedImportPath === srcDir || normalizedImportPath.startsWith(`${srcDir}/`))) { const trimmed = normalizedImportPath.startsWith(`${srcDir}/`) ? normalizedImportPath.slice(srcDir.length + 1) : normalizedImportPath; candidates.push(normalizedImportPath, trimmed); if (analysis.rootDir) { candidates.push(normalizePathValue(resolve(analysis.rootDir, normalizedImportPath))); } } else { return null; } for (const candidate of candidates) { for (const key of expandPathKeys(candidate)) { const resolved = lookup.get(key); if (resolved) return resolved; } } return null; }