import { GUARD_RULES, FORBIDDEN_IMPORTS, type GuardViolation } from "./rules"; import { runContractGuardCheck } from "./contract-guard"; import { validateSlotContent } from "../slot/validator"; import type { RoutesManifest } from "../spec/schema"; import type { GeneratedMap } from "../generator/generate"; import { loadManduConfig, type GuardRuleSeverity } from "../config"; import { extractImportsAST, extractExportsAST } from "./ast-analyzer"; import { validateCustomRules, type GuardRule as CustomGuardRule, type GuardRuleContext as CustomGuardRuleContext, type GuardViolation as CustomGuardViolation, } from "./define-rule"; import type { ManduConfig } from "../config/mandu"; import path from "path"; import fs from "fs/promises"; export interface GuardCheckResult { passed: boolean; violations: GuardViolation[]; } /** * Canonical docs URL for the `getGenerated()` runtime-registry pattern. * Shared by the Guard `INVALID_GENERATED_IMPORT` rule and the bundler * plugin `block-generated-imports` so both paths surface the same * remediation target. */ export const GENERATED_IMPORT_DOCS_URL = "https://mandujs.com/docs/architect/generated-access"; /** * Build the user-facing message for a detected direct generated-artifact * import. `specifier` is the literal import string that tripped the guard * (not the resolved path). * * This helper is the single source of truth for the message text — both * the static Guard pass (`checkInvalidGeneratedImport`) and the bundler * plugin (`blockGeneratedImports`) call through it so the two paths * cannot drift. */ export function buildForbiddenGeneratedImportMessage(specifier: string): string { return ( `Direct generated artifact imports are forbidden: ${specifier}. ` + `Use the runtime registry: see ${GENERATED_IMPORT_DOCS_URL}` ); } /** * Shared remediation hint. Points at `getGenerated()` from * `@mandujs/core/runtime` and the decision-tree docs. */ export const FORBIDDEN_GENERATED_IMPORT_SUGGESTION = "Import getGenerated() from @mandujs/core/runtime and read the generated artifact through the manifest. " + `See ${GENERATED_IMPORT_DOCS_URL} for the decision tree.`; function normalizeSeverity(level: GuardRuleSeverity): "error" | "warning" | "off" { if (level === "warn") return "warning"; return level; } function applyRuleSeverity( violations: GuardViolation[], config: { rules?: Record; contractRequired?: GuardRuleSeverity } ): GuardViolation[] { const resolved: GuardViolation[] = []; const ruleOverrides = config.rules ?? {}; for (const violation of violations) { let override = ruleOverrides[violation.ruleId]; if (violation.ruleId === "CONTRACT_MISSING" && config.contractRequired) { override = config.contractRequired; } const baseSeverity = violation.severity ?? GUARD_RULES[violation.ruleId]?.severity ?? "error"; const finalSeverity = override ? normalizeSeverity(override) : baseSeverity; if (finalSeverity === "off") continue; resolved.push({ ...violation, severity: finalSeverity }); } return resolved; } async function fileExists(filePath: string): Promise { try { await fs.access(filePath); return true; } catch { return false; } } async function readFileContent(filePath: string): Promise { try { return await Bun.file(filePath).text(); } catch { return null; } } // Rule 2: Generated file manual edit detection export async function checkGeneratedManualEdit( rootDir: string, generatedMap: GeneratedMap ): Promise { const violations: GuardViolation[] = []; for (const [filePath, meta] of Object.entries(generatedMap.files)) { const fullPath = path.join(rootDir, filePath); const content = await readFileContent(fullPath); if (!content) continue; // Check if the "DO NOT EDIT" comment was removed or modified if (!content.includes("Generated by Mandu - DO NOT EDIT DIRECTLY")) { violations.push({ ruleId: GUARD_RULES.GENERATED_MANUAL_EDIT.id, file: filePath, message: `generated 파일이 수동으로 변경된 것으로 보입니다 (routeId: ${meta.routeId})`, suggestion: "bunx mandu generate를 실행하여 파일을 재생성하세요", }); } } return violations; } // Rule 3: Non-generated importing generated export async function checkInvalidGeneratedImport( rootDir: string ): Promise { const violations: GuardViolation[] = []; // Scan non-generated source files const sourceDirs = [ path.join(rootDir, "packages"), path.join(rootDir, "src"), path.join(rootDir, "app"), ]; for (const sourceDir of sourceDirs) { const files = await scanTsFiles(sourceDir); for (const file of files) { // Skip generated directories if (file.includes("/generated/") || file.includes("\\generated\\")) { continue; } const content = await readFileContent(file); if (!content) continue; // Check for imports from generated directories const importRegex = /import\s+.*from\s+['"](.*generated.*)['"]/g; let match; while ((match = importRegex.exec(content)) !== null) { const relativePath = path.relative(rootDir, file); violations.push({ ruleId: GUARD_RULES.INVALID_GENERATED_IMPORT.id, file: relativePath, message: buildForbiddenGeneratedImportMessage(match[1]), suggestion: FORBIDDEN_GENERATED_IMPORT_SUGGESTION, }); } } } return violations; } // Rule 5: Slot file existence check export async function checkSlotFileExists( manifest: RoutesManifest, rootDir: string ): Promise { const violations: GuardViolation[] = []; for (const route of manifest.routes) { if (route.slotModule) { const slotPath = path.join(rootDir, route.slotModule); const exists = await fileExists(slotPath); if (!exists) { violations.push({ ruleId: GUARD_RULES.SLOT_NOT_FOUND.id, file: route.slotModule, message: `Slot 파일을 찾을 수 없습니다 (routeId: ${route.id})`, suggestion: "bunx mandu generate를 실행하여 slot 파일을 생성하세요", }); } } } return violations; } // Rule 6: Slot content validation (신규) export async function checkSlotContentValidation( manifest: RoutesManifest, rootDir: string ): Promise { const violations: GuardViolation[] = []; for (const route of manifest.routes) { if (!route.slotModule) continue; const slotPath = path.join(rootDir, route.slotModule); const content = await readFileContent(slotPath); if (!content) continue; // File doesn't exist, handled by checkSlotFileExists const validationResult = validateSlotContent(content); // Convert slot validation issues to guard violations for (const issue of validationResult.issues) { if (issue.severity === "error") { // Map slot issue codes to guard rule IDs let ruleId = "SLOT_VALIDATION_ERROR"; if (issue.code === "MISSING_DEFAULT_EXPORT") { ruleId = GUARD_RULES.SLOT_MISSING_DEFAULT_EXPORT?.id ?? "SLOT_MISSING_DEFAULT_EXPORT"; } else if (issue.code === "NO_RESPONSE_PATTERN" || issue.code === "INVALID_HANDLER_RETURN") { ruleId = GUARD_RULES.SLOT_INVALID_RETURN?.id ?? "SLOT_INVALID_RETURN"; } else if (issue.code === "MISSING_FILLING_PATTERN") { ruleId = GUARD_RULES.SLOT_MISSING_FILLING_PATTERN?.id ?? "SLOT_MISSING_FILLING_PATTERN"; } else if (issue.code === "ZOD_DIRECT_IMPORT") { ruleId = GUARD_RULES.SLOT_ZOD_DIRECT_IMPORT?.id ?? "SLOT_ZOD_DIRECT_IMPORT"; } violations.push({ ruleId, file: route.slotModule, message: `[${route.id}] ${issue.message}`, suggestion: issue.suggestion, line: issue.line, severity: issue.severity, }); } } } return violations; } // Rule: Island-First Integrity export async function checkIslandFirstIntegrity( manifest: RoutesManifest, rootDir: string ): Promise { const violations: GuardViolation[] = []; for (const route of manifest.routes) { if (route.kind !== "page" || !route.clientModule) continue; // 1. clientModule 파일 존재 여부 const clientPath = path.join(rootDir, route.clientModule); if (!(await fileExists(clientPath))) { violations.push({ ruleId: "CLIENT_MODULE_NOT_FOUND", file: route.clientModule, message: `clientModule 파일을 찾을 수 없습니다 (routeId: ${route.id})`, suggestion: "clientModule 경로를 확인하거나 파일을 생성하세요", }); continue; } // 2. Island-First integrity: verify that a .island.tsx or .client.tsx file // exists alongside the page's componentModule. The page does NOT need to // directly import the island - the framework auto-links them via // data-island attributes and the manifest's clientModule field. if (route.componentModule && route.clientModule) { const componentPath = path.join(rootDir, route.componentModule); const content = await readFileContent(componentPath); // Check if the island file actually exists on disk const clientPath = path.join(rootDir, route.clientModule); const clientExists = await fileExists(clientPath); if (content && !clientExists && !content.includes("data-island") && !content.includes("data-mandu-island")) { violations.push({ ruleId: "ISLAND_FIRST_INTEGRITY", file: route.componentModule, message: `No island file found for page route (routeId: ${route.id}). The clientModule '${route.clientModule}' does not exist.`, suggestion: "Create a .island.tsx file in the same app/ directory as page.tsx. " + "The page should reference islands via data-island attributes, NOT by directly importing or re-exporting the island. " + "Importing island() return values into page.tsx causes a runtime crash because they are config objects, not React components.", }); } } } return violations; } // Rule 4: Forbidden imports in generated files export async function checkForbiddenImportsInGenerated( rootDir: string, generatedMap: GeneratedMap ): Promise { const violations: GuardViolation[] = []; for (const [filePath] of Object.entries(generatedMap.files)) { const fullPath = path.join(rootDir, filePath); const content = await readFileContent(fullPath); if (!content) continue; for (const forbidden of FORBIDDEN_IMPORTS) { const importRegex = new RegExp( `import\\s+.*from\\s+['"]${forbidden}['"]|require\\s*\\(\\s*['"]${forbidden}['"]\\s*\\)`, "g" ); if (importRegex.test(content)) { violations.push({ ruleId: GUARD_RULES.FORBIDDEN_IMPORT_IN_GENERATED.id, file: filePath, message: `generated 파일에서 금지된 모듈 '${forbidden}' import 감지`, suggestion: `'${forbidden}' 모듈 사용이 필요하면 slot 로직에서 처리하세요`, }); } } } return violations; } async function scanTsFiles(dir: string): Promise { const files: string[] = []; try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name !== "node_modules" && entry.name !== "dist") { const subFiles = await scanTsFiles(fullPath); files.push(...subFiles); } } else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx"))) { files.push(fullPath); } } } catch { // Directory doesn't exist or can't be read } return files; } // Rule: spec/ directory naming convention scan export async function checkSpecDirNaming( rootDir: string ): Promise { const violations: GuardViolation[] = []; // Check spec/slots/ — only .slot.ts allowed const slotsDir = path.join(rootDir, "spec/slots"); try { const files = await fs.readdir(slotsDir); for (const file of files) { if (file.endsWith(".ts") && !file.endsWith(".slot.ts")) { violations.push({ ruleId: GUARD_RULES.SLOT_DIR_INVALID_FILE.id, file: `spec/slots/${file}`, message: `spec/slots/에 .slot.ts가 아닌 파일: ${file}`, suggestion: `.slot.ts로 이름을 바꾸거나, 이 파일이 client slot이면 spec/slots/로 .client.ts 접미사로 이동하세요`, }); } } } catch {} // Check spec/contracts/ — only .contract.ts allowed const contractsDir = path.join(rootDir, "spec/contracts"); try { const files = await fs.readdir(contractsDir); for (const file of files) { if (file.endsWith(".ts") && !file.endsWith(".contract.ts")) { violations.push({ ruleId: GUARD_RULES.CONTRACT_DIR_INVALID_FILE.id, file: `spec/contracts/${file}`, message: `spec/contracts/에 .contract.ts가 아닌 파일: ${file}`, suggestion: `.contract.ts로 이름을 바꾸세요`, }); } } } catch {} return violations; } export async function runGuardCheck( manifest: RoutesManifest, rootDir: string ): Promise { const config = await loadManduConfig(rootDir); const mapPath = path.join(rootDir, ".mandu/generated/generated.map.json"); // ============================================ // Phase 1: 독립적인 검사 병렬 실행 // ============================================ const [ importViolations, slotViolations, specDirViolations, islandViolations, ] = await Promise.all([ checkInvalidGeneratedImport(rootDir), checkSlotFileExists(manifest, rootDir), checkSpecDirNaming(rootDir), checkIslandFirstIntegrity(manifest, rootDir), ]); const violations: GuardViolation[] = []; violations.push(...importViolations); violations.push(...slotViolations); violations.push(...specDirViolations); violations.push(...islandViolations); // ============================================ // Phase 2: generatedMap 의존 검사 // ============================================ let generatedMap: GeneratedMap | null = null; if (await fileExists(mapPath)) { try { const mapContent = await Bun.file(mapPath).text(); generatedMap = JSON.parse(mapContent); } catch { // Map file corrupted or missing } } if (generatedMap) { const [editViolations, forbiddenViolations] = await Promise.all([ checkGeneratedManualEdit(rootDir, generatedMap), checkForbiddenImportsInGenerated(rootDir, generatedMap), ]); violations.push(...editViolations); violations.push(...forbiddenViolations); } // ============================================ // Phase 3: Slot + Contract 검사 병렬 // ============================================ const [slotContentViolations, contractViolations] = await Promise.all([ checkSlotContentValidation(manifest, rootDir), runContractGuardCheck(manifest, rootDir), ]); violations.push(...slotContentViolations); violations.push(...contractViolations); // ============================================ // Issue #245 — DESIGN.md-driven inline-class enforcement. // Skipped silently when guard.design is undefined or has nothing // to enforce (no explicit forbid list + autoFromDesignMd off). // ============================================ const designConfig = (config.guard as { design?: unknown } | undefined)?.design; if (designConfig && typeof designConfig === "object") { const { checkDesignInlineClasses } = await import("./design-inline-class"); const designViolations = await checkDesignInlineClasses( rootDir, designConfig as Parameters[1], ); violations.push(...designViolations); } // ============================================ // Phase 18.ν — Consumer-defined custom rules // ============================================ const customRules = (config.guard?.rules as unknown); if (Array.isArray(customRules) && customRules.length > 0) { const customViolations = await runCustomRules( customRules as CustomGuardRule[], rootDir, config ); violations.push(...customViolations); } const resolvedViolations = applyRuleSeverity(violations, normalizeGuardConfigForSeverity(config.guard)); const passed = resolvedViolations.every((v) => v.severity !== "error"); return { passed, violations: resolvedViolations, }; } // ═══════════════════════════════════════════════════════════════════════════ // Phase 18.ν — Consumer-defined Guard rules // ═══════════════════════════════════════════════════════════════════════════ /** * The existing `config.guard.rules` field was a `Record` map for built-in rule overrides. Phase 18.ν * allows consumers to also pass an array of `GuardRule` objects. * `applyRuleSeverity` only understands the record shape, so we strip * the array out before forwarding to it. */ function normalizeGuardConfigForSeverity( guard: ManduConfig["guard"] | undefined ): { rules?: Record; contractRequired?: GuardRuleSeverity } { if (!guard) return {}; const rulesField = guard.rules as unknown; const rules = rulesField && !Array.isArray(rulesField) && typeof rulesField === "object" ? (rulesField as Record) : undefined; return { rules, contractRequired: (guard as { contractRequired?: GuardRuleSeverity }).contractRequired, }; } /** * Parallel scan cap for `runCustomRules()`. Matches `safeBuild` default * (`MANDU_BUN_BUILD_CONCURRENCY`) semantics — tunable via * `MANDU_GUARD_CUSTOM_CONCURRENCY` positive integer env var. */ function customRuleConcurrency(): number { const raw = process.env.MANDU_GUARD_CUSTOM_CONCURRENCY; if (!raw) return 8; const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed) || parsed < 1) return 8; return parsed; } /** Default source directories scanned for custom rules. */ const CUSTOM_RULE_SOURCE_DIRS = ["packages", "src", "app"]; async function collectCustomRuleSourceFiles(rootDir: string): Promise { const files = await Promise.all( CUSTOM_RULE_SOURCE_DIRS.map((d) => scanTsFiles(path.join(rootDir, d))) ); // Deduplicate paths (a file could in theory live under multiple roots // if a consumer symlinks, which the walker doesn't follow but we // guard anyway). return Array.from(new Set(files.flat())); } /** * Convert a consumer-defined `GuardViolation` into the standard Guard * report `GuardViolation` shape. Adds the `custom:` ruleId prefix * so the reporter can attribute each entry unambiguously. */ function toReportViolation( rule: CustomGuardRule, violation: CustomGuardViolation ): GuardViolation { const severity: "error" | "warning" = rule.severity === "error" ? "error" : "warning"; return { ruleId: `custom:${rule.id}`, file: violation.file, message: violation.message, suggestion: violation.hint ?? violation.docsUrl ?? "", line: violation.line, severity, }; } /** * Execute every consumer-defined rule against every source file under * `rootDir`. Rules are awaited per-file with a concurrency cap; a * single rule throwing is caught and reported as a `custom:` * violation so the rest of the scan still completes. * * Exported for tests; not re-exported via `guard/index.ts` to keep the * public surface small. */ export async function runCustomRules( rules: readonly CustomGuardRule[], rootDir: string, config: ManduConfig ): Promise { const { duplicates, malformed } = validateCustomRules(rules); const results: GuardViolation[] = []; if (malformed.length > 0) { for (const idx of malformed) { results.push({ ruleId: "custom:__invalid__", file: "mandu.config", message: `guard.rules[${idx}] is not a valid GuardRule (missing \`id\` or \`check()\`).`, suggestion: "Wrap the rule with defineGuardRule({...}) or import a preset from @mandujs/core/guard/define-rule.", severity: "error", }); } } if (duplicates.length > 0) { for (const dup of duplicates) { // Warning-level so duplicates don't gate CI but still surface in the // report. Matches the "soft failure" convention used by config-guard. results.push({ ruleId: `custom:${dup.id}`, file: "mandu.config", message: `Duplicate guard.rules id "${dup.id}" at indices [${dup.indices.join(", ")}] — only the first instance will be enforced.`, suggestion: "Give each rule a unique \`id\`, or remove the duplicate.", severity: "warning", }); } } // Deduplicate rules by id (keep first occurrence). Matches the // warning emitted above. const seen = new Set(); const uniqueRules = rules.filter((r) => { if (!r || typeof r !== "object" || typeof (r as CustomGuardRule).id !== "string") return false; const id = (r as CustomGuardRule).id; if (seen.has(id)) return false; seen.add(id); return true; }); if (uniqueRules.length === 0) return results; const files = await collectCustomRuleSourceFiles(rootDir); if (files.length === 0) return results; const cap = customRuleConcurrency(); let cursor = 0; async function worker(): Promise { const local: GuardViolation[] = []; while (true) { const index = cursor++; if (index >= files.length) return local; const filePath = files[index]; // Skip __generated__ and build output. if (filePath.includes("__generated__") || filePath.includes(".mandu/")) continue; let content: string; try { content = await Bun.file(filePath).text(); } catch { continue; } let imports: ReturnType; let exportsAst: ReturnType; try { imports = extractImportsAST(content); exportsAst = extractExportsAST(content); } catch { // Tokenizer failure on an exotic file — skip rather than abort. continue; } const relFile = path.relative(rootDir, filePath) || filePath; const ctx: CustomGuardRuleContext = { sourceFile: relFile.replace(/\\/g, "/"), content, imports, exports: exportsAst, config, projectRoot: rootDir, }; for (const rule of uniqueRules) { try { const violations = await rule.check(ctx); if (!Array.isArray(violations)) continue; for (const v of violations) { if (!v || typeof v !== "object") continue; local.push(toReportViolation(rule, v)); } } catch (err) { const message = err instanceof Error ? err.message : String(err); local.push({ ruleId: `custom:${rule.id}`, file: ctx.sourceFile, message: `Rule "${rule.id}" threw: ${message}`, suggestion: "Fix the rule's check() implementation or wrap it in a try/catch.", severity: rule.severity === "error" ? "error" : "warning", }); } } } } const workerCount = Math.min(cap, files.length); const workers: Promise[] = []; for (let i = 0; i < workerCount; i++) workers.push(worker()); const chunks = await Promise.all(workers); for (const chunk of chunks) results.push(...chunk); return results; }