#!/usr/bin/env node /** * Contract Consistency Test * * 验证一个由 Pipeline 生成的项目中: * 1. Schema 字段 100% 匹配 Contract 定义 * 2. Types 字段名(camelCase)100% 匹配 Contract 的 fieldMapping * 3. Routes 的 DTO 类型 100% 匹配 Contract * 4. Services 的 SQL 列名 100% 匹配 Contract * 5. Tests 使用的字段名 100% 匹配 Contract * * 验证失败则 exit(1),成功 exit(0)。 * 输出 contract-consistency-report.md。 * * Usage: * node scripts/contract-consistency-test.mjs --project [--prd ] [--arch ] * * Options: * --project 项目根目录 * --prd PRD JSON 文件路径(用于生成 Contract) * --arch Architecture JSON 文件路径(用于生成 Contract) * --help 显示帮助 */ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { createContract, toCamel, toSnake } from "./model-contract.mjs"; const __dirname = dirname(resolve(fileURLToPath(import.meta.url))); const WORKSPACE = resolve(__dirname, ".."); // ═══════════════════════════════════════════════════════ // 1. Test Infrastructure // ═══════════════════════════════════════════════════════ class ContractConsistencyTest { constructor(contract) { this.contract = contract; this.failures = []; this.warnings = []; this.checks = { total: 0, passed: 0, failed: 0 }; } fail(section, message) { this.failures.push({ section, message }); this.checks.failed++; this.checks.total++; } pass() { this.checks.passed++; this.checks.total++; } /** * Recursively find files matching a glob in a directory. */ findFiles(dir, pattern) { const results = []; if (!existsSync(dir)) return results; try { const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = resolve(dir, entry.name); if (entry.isDirectory()) { // Skip node_modules and dist if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".next" || entry.name === "data") continue; results.push(...this.findFiles(fullPath, pattern)); } else if (pattern.test(entry.name)) { results.push(fullPath); } } } catch { /* skip */ } return results; } } // ═══════════════════════════════════════════════════════ // 2. Check 1: Schema → Contract // ═══════════════════════════════════════════════════════ function checkSchemaConsistency(test) { const { contract } = test; const entities = contract.entities; console.log("── Check 1: Database Schema ↔ Contract ──"); // Find schema files const projectDir = process.env.PROJECT_DIR || "."; const schemaFiles = [ ...test.findFiles(resolve(projectDir, "backend/src/db"), /schema\.ts$/), ...test.findFiles(resolve(projectDir, "src/db"), /schema\.ts$/), ...test.findFiles(resolve(projectDir, "apps/api/src/db"), /schema\.ts$/), ...test.findFiles(resolve(projectDir, "fullstack/apps/api/src/db"), /schema\.ts$/), ...test.findFiles(projectDir, /schema\.prisma$/), ]; if (schemaFiles.length === 0) { test.fail("Schema", "No schema file found (src/db/schema.ts or schema.prisma)"); return; } for (const schemaFile of schemaFiles) { console.log(` Schema file: ${schemaFile}`); let content; try { content = readFileSync(schemaFile, "utf-8"); } catch (e) { test.fail("Schema", `Cannot read ${schemaFile}: ${e.message}`); continue; } for (const entity of entities) { console.log(` Checking entity: ${entity.name} (${entity.table})`); // Verify the table exists in the schema const tableRegex = new RegExp(`CREATE TABLE IF NOT EXISTS ${entity.table} \\(([\\s\\S]+?)\\);`, "si"); const tableMatch = content.match(tableRegex); if (!tableMatch) { // Try without CREATE TABLE (for Prisma/simpler formats) const altRegex = new RegExp(`(?:CREATE TABLE|model)\\s+(?:IF NOT EXISTS\\s+)?['"]?${entity.table}['"]?`, "i"); if (!altRegex.test(content)) { test.warnings.push({ section: "Schema", message: `Table '${entity.table}' not found in schema` }); continue; } } // Extract column definitions let columns; if (tableMatch) { const body = tableMatch[1]; columns = body .split(",") .map(c => c.trim()) .filter(c => c.length > 0 && !c.startsWith("--")) .map(c => { // Handle DEFAULT (...) expressions with nested parens const cleanCol = c.replace(/DEFAULT\s*\([^)]*\)/gi, "").replace(/^\\n\s*/, "").trim(); const parts = cleanCol.split(/\s+/); return { name: parts[0], raw: c }; }); } else { columns = []; } const columnNames = new Set(columns.map(c => c.name)); for (const field of entity.fields) { test.pass(); if (field.isSecret) continue; // Secret fields may not be in output types if (!columnNames.has(field.name)) { test.fail( `Schema/${entity.table}`, `Field '${field.name}' defined in Contract but MISSING from schema table '${entity.table}'` ); } } // Check for extra columns not in contract (warn only) for (const col of columns) { const hasField = entity.fields.some(f => f.name === col.name); if (!hasField && !col.raw.toUpperCase().includes("FOREIGN KEY") && !col.raw.toUpperCase().includes("PRIMARY KEY") && !col.raw.toUpperCase().includes("CONSTRAINT") && !col.raw.toUpperCase().includes("UNIQUE(") && !col.raw.toUpperCase().includes("CHECK(") && col.name !== "" && !col.raw.startsWith(")")) { test.warnings.push({ section: `Schema/${entity.table}`, message: `Column '${col.name}' found in schema but NOT in Contract for table '${entity.table}'` }); } } } } console.log(` Schema checks: ${test.checks.passed - (test.checks.passed > 0 ? 0 : 0)} passed, ${test.checks.failed} failures`); } // ═══════════════════════════════════════════════════════ // 3. Check 2: Types → Contract // ═══════════════════════════════════════════════════════ function checkTypesConsistency(test) { const { contract } = test; const projectDir = process.env.PROJECT_DIR || "."; console.log("── Check 2: TypeScript Types ↔ Contract ──"); const typeFiles = [ ...test.findFiles(resolve(projectDir, "backend/src/types"), /\.ts$/), ...test.findFiles(resolve(projectDir, "src/types"), /\.ts$/), ...test.findFiles(resolve(projectDir, "apps/api/src/types"), /\.ts$/), ...test.findFiles(resolve(projectDir, "apps/api/packages/shared-types"), /\.ts$/), ...test.findFiles(resolve(projectDir, "packages/shared-types"), /\.ts$/), ].filter(f => !f.endsWith(".d.ts") || f.endsWith("fastify.d.ts")); if (typeFiles.length === 0) { test.fail("Types", "No TypeScript type files found"); return; } // Build a set of all expected camelCase field names from the contract const expectedFields = new Map(); // entityName → Set of field names for (const entity of contract.entities) { const fieldSet = new Set(); for (const field of entity.fields) { if (field.isSecret) continue; const camel = toCamel(contract, field.name); fieldSet.add(camel); } expectedFields.set(entity.name, fieldSet); } for (const typeFile of typeFiles) { console.log(` Types file: ${typeFile}`); let content; try { content = readFileSync(typeFile, "utf-8"); } catch (e) { test.fail("Types", `Cannot read ${typeFile}: ${e.message}`); continue; } // For each entity, check its interface exists and has correct fields for (const entity of contract.entities) { const expected = expectedFields.get(entity.name); if (!expected) continue; // Find the interface declaration const ifaceRegex = new RegExp(`(?:export\\s+)?interface\\s+${entity.name}\\s*(?:extends\\s+[^{]+)?\\s*\\{([^}]*)\\}`, "s"); const match = content.match(ifaceRegex); if (!match) { // Entity types might not be directly in this file (e.g. in shared-types) continue; } const body = match[1]; const fieldLines = body .split("\n") .map(l => l.trim()) .filter(l => l.length > 0 && !l.startsWith("//")); const foundFields = new Set(); for (const line of fieldLines) { // Extract field name: " username: string;" or " username?: string;" const fieldMatch = line.match(/^\s*(\w+)(\?)?\s*:/); if (fieldMatch) { foundFields.add(fieldMatch[1]); } } // Check each expected field exists for (const fieldName of expected) { test.pass(); if (!foundFields.has(fieldName)) { test.fail( `Types/${entity.name}`, `Field '${fieldName}' (camelCase) expected in interface '${entity.name}' but MISSING from types file` ); } } // Warn about extra fields for (const found of foundFields) { if (!expected.has(found)) { test.warnings.push({ section: `Types/${entity.name}`, message: `Field '${found}' found in interface '${entity.name}' but NOT in Contract` }); } } } } // Also check that all entities exist as interfaces in the types // Find ALL interface names declared for (const typeFile of typeFiles) { let content; try { content = readFileSync(typeFile, "utf-8"); } catch { continue; } for (const entity of contract.entities) { const ifaceRegex = new RegExp(`interface\\s+${entity.name}\\s*(?:extends|\\{)`, "s"); if (!ifaceRegex.test(content)) { // Check if entity's table is users — User is a special type if (entity.table !== "users") { // It might be in another type file; we accumulate all continue; } } } } } // ═══════════════════════════════════════════════════════ // 4. Check 3: Routes → Contract // ═══════════════════════════════════════════════════════ function checkRoutesConsistency(test) { const { contract } = test; const projectDir = process.env.PROJECT_DIR || "."; console.log("── Check 3: Route DTO Types ↔ Contract ──"); const routeFiles = [ ...test.findFiles(resolve(projectDir, "src/routes"), /\.ts$/), ...test.findFiles(resolve(projectDir, "apps/api/src/routes"), /\.ts$/), ]; if (routeFiles.length === 0) { test.warnings.push({ section: "Routes", message: "No route files found, skipping route checks" }); return; } for (const routeFile of routeFiles) { console.log(` Route file: ${routeFile}`); let content; try { content = readFileSync(routeFile, "utf-8"); } catch (e) { continue; } // Check auth routes use contract auth config // Check CRUD routes use correct entity field names for (const entity of contract.entities) { if (entity.table === "users") continue; // Skip User — handled by auth routes const routeName = entity.table; if (routeFile.includes(routeName) || routeFile.endsWith(`${routeName}.ts`)) { // Verify CreateInput and UpdateInput types are used const pascalName = entity.name; const hasCreateInput = new RegExp(`Create${pascalName}Input`).test(content); const hasUpdateInput = new RegExp(`Update${pascalName}Input`).test(content); if (!hasCreateInput) { test.fail( `Routes/${routeName}`, `Route for '${routeName}' should use Create${pascalName}Input type` ); } else { test.pass(); } if (!hasUpdateInput) { test.fail( `Routes/${routeName}`, `Route for '${routeName}' should use Update${pascalName}Input type` ); } else { test.pass(); } } } } } // ═══════════════════════════════════════════════════════ // 5. Check 4: Services → Contract // ═══════════════════════════════════════════════════════ function checkServicesConsistency(test) { const { contract } = test; const projectDir = process.env.PROJECT_DIR || "."; console.log("── Check 4: Service SQL Columns ↔ Contract ──"); const serviceFiles = [ ...test.findFiles(resolve(projectDir, "src/services"), /\.ts$/), ...test.findFiles(resolve(projectDir, "apps/api/src/services"), /\.ts$/), ]; if (serviceFiles.length === 0) { test.warnings.push({ section: "Services", message: "No service files found, skipping service checks" }); return; } for (const serviceFile of serviceFiles) { console.log(` Service file: ${serviceFile}`); let content; try { content = readFileSync(serviceFile, "utf-8"); } catch (e) { continue; } // Extract SQL column names from INSERT/SELECT statements const insertMatches = content.matchAll(/INSERT INTO\s+(\w+)\s*\(([^)]+)\)/gi); for (const match of insertMatches) { const tableName = match[1]; const colStr = match[2]; const columns = colStr.split(",").map(c => c.trim().replace(/"/g, "")); // Find the entity for this table const entity = contract.entities.find(e => e.table === tableName); if (!entity) continue; const contractCols = new Set(entity.fields.filter(f => !f.isSecret).map(f => f.name)); for (const col of columns) { test.pass(); if (!contractCols.has(col)) { test.fail( `Services/${tableName}`, `SQL column '${col}' in INSERT statement not found in Contract for table '${tableName}'` ); } } } // Check SELECT statements too const selectMatches = content.matchAll(/SELECT\s+(.+?)\s+FROM\s+(\w+)/gi); for (const match of selectMatches) { const selectStr = match[1]; const tableName = match[2]; const entity = contract.entities.find(e => e.table === tableName); if (!entity || selectStr === "*") continue; const selectCols = selectStr .split(",") .map(c => c.trim().split(/\s+as\s+/i)[0].trim()) .filter(c => c.length > 0 && c !== "*"); const contractCols = new Set(entity.fields.filter(f => !f.isSecret).map(f => f.name)); for (const col of selectCols) { // Column might be aliased or use table prefix const bareCol = col.split(".").pop().replace(/"/g, ""); if (!contractCols.has(bareCol)) { test.warnings.push({ section: `Services/${tableName}`, message: `SQL selected column '${bareCol}' not found in Contract for table '${tableName}'` }); } } } } } // ═══════════════════════════════════════════════════════ // 6. Check 5: Tests → Contract // ═══════════════════════════════════════════════════════ function checkTestsConsistency(test) { const { contract } = test; const projectDir = process.env.PROJECT_DIR || "."; console.log("── Check 5: Test Payloads ↔ Contract ──"); const testFiles = [ ...test.findFiles(resolve(projectDir, "src/__tests__"), /\.test\.ts$/), ...test.findFiles(resolve(projectDir, "apps/api/src/__tests__"), /\.test\.ts$/), ...test.findFiles(projectDir, /\.test\.ts$/), ]; if (testFiles.length === 0) { test.warnings.push({ section: "Tests", message: "No test files found, skipping test checks" }); return; } for (const testFile of testFiles) { console.log(` Test file: ${testFile}`); let content; try { content = readFileSync(testFile, "utf-8"); } catch (e) { continue; } // Extract payload objects from .inject() calls const payloadRegex = /payload:\s*({[^}]+})/gs; const matches = [...content.matchAll(payloadRegex)]; for (const match of matches) { let payloadStr = match[1]; try { // Try to parse as JSON (might have template literals, so this is approximate) // Extract field names using regex const fieldRegex = /(\w+)\s*:/g; const payloadFields = []; let fm; while ((fm = fieldRegex.exec(payloadStr)) !== null) { payloadFields.push(fm[1]); } // Check if these fields are valid according to some entity const allContractFields = new Set(); for (const entity of contract.entities) { for (const field of entity.fields) { if (!field.isSecret && !field.isAuto && !field.isPrimary) { allContractFields.add(toCamel(contract, field.name)); allContractFields.add(field.name); // snake_case } } } for (const pf of payloadFields) { if (!allContractFields.has(pf)) { // Allow special test fields if (["username", "password", "nickname", "token", "authorization"].includes(pf)) { continue; } test.warnings.push({ section: `Tests/${testFile}`, message: `Test payload field '${pf}' not found in any Contract entity` }); } } } catch { // Non-JSON payload, skip } } // Check for hardcoded fake userId values const fakeIdMatch = content.match(/"00000000-0000-0000-0000-000000000001"/); if (fakeIdMatch) { test.warnings.push({ section: `Tests/${testFile}`, message: "Hardcoded fake userId found in test. Consider using a real registered user ID." }); } } } // ═══════════════════════════════════════════════════════ // 7. Check 6: Auth Configuration // ═══════════════════════════════════════════════════════ function checkAuthConsistency(test) { const { contract } = test; const projectDir = process.env.PROJECT_DIR || "."; console.log("── Check 7: Auth Config ↔ Contract ──"); const authFiles = [ ...test.findFiles(resolve(projectDir, "src/routes"), /auth\.ts$/), ...test.findFiles(resolve(projectDir, "apps/api/src/routes"), /auth\.ts$/), ]; if (authFiles.length === 0) { test.warnings.push({ section: "Auth", message: "No auth routes found" }); return; } for (const authFile of authFiles) { let content; try { content = readFileSync(authFile, "utf-8"); } catch (e) { continue; } // Check registration fields for (const field of contract.auth.registrationFields) { test.pass(); if (!content.includes(field)) { test.fail("Auth", `Registration field '${field}' from contract.auth.registrationFields not found in auth routes`); } } // Check JWT payload fields for (const field of contract.auth.jwtPayload) { test.pass(); if (!content.includes(field)) { test.fail("Auth", `JWT payload field '${field}' from contract.auth.jwtPayload not found in auth routes`); } } // Check password policy (minLength) const pwPolicy = contract.auth.passwordPolicy; if (pwPolicy.minLength) { const minLenRegex = new RegExp(`password\\.length\\s*<\\s*${pwPolicy.minLength}`); if (minLenRegex.test(content)) { test.pass(); } else { test.fail("Auth", `Password minLength policy (${pwPolicy.minLength}) not enforced in auth routes`); } } // Check password_hash column is used (not a plain password column) test.pass(); if (!content.includes("password_hash") && !content.includes("passwordHash")) { test.fail("Auth", "Auth routes should use 'password_hash' column (not plain password)"); } } } // ═══════════════════════════════════════════════════════ // 8. Main Runner // ═══════════════════════════════════════════════════════ function generateReport(test, contract) { const lines = []; lines.push("# Contract Consistency Report"); lines.push(""); lines.push(`**Generated**: ${new Date().toISOString()}`); lines.push(`**Project**: ${contract.projectName}`); lines.push(`**Domain**: ${contract.domain}`); lines.push(`**Entities**: ${contract.entities.length}`); lines.push(""); lines.push("## Summary"); lines.push(""); lines.push(`| Metric | Value |`); lines.push(`|--------|-------|`); lines.push(`| Total Checks | ${test.checks.total} |`); lines.push(`| Passed | ${test.checks.passed} |`); lines.push(`| Failed | ${test.checks.failed} |`); lines.push(`| Warnings | ${test.warnings.length} |`); lines.push(`| **Result** | **${test.checks.failed === 0 ? "✅ PASS" : "❌ FAIL"}** |`); lines.push(""); if (test.failures.length > 0) { lines.push("## ❌ Failures"); lines.push(""); for (const f of test.failures) { lines.push(`- **${f.section}**: ${f.message}`); } lines.push(""); } if (test.warnings.length > 0) { lines.push("## ⚠️ Warnings"); lines.push(""); for (const w of test.warnings) { lines.push(`- **${w.section}**: ${w.message}`); } lines.push(""); } lines.push("## Contract Entities"); lines.push(""); for (const entity of contract.entities) { lines.push(`### ${entity.name} (\`${entity.table}\`)`); lines.push(`*${entity.description}*`); lines.push(""); lines.push("| Field | Type | Required | Notes |"); lines.push("|-------|------|----------|-------|"); for (const field of entity.fields) { const notes = []; if (field.isPrimary) notes.push("PK"); if (field.isAuto) notes.push("auto"); if (field.isSecret) notes.push("secret"); if (field.unique) notes.push("unique"); if (field.enum) notes.push(`enum: ${field.enum.join("|")}`); if (field.defaultValue !== undefined) notes.push(`default: ${field.defaultValue}`); if (field.fkEntity) notes.push(`FK→${field.fkEntity}.${field.fkColumn}`); lines.push(`| \`${field.name}\` | ${field.type} | ${field.required ? "✓" : ""} | ${notes.join(", ")} |`); } lines.push(""); } // Field mapping reference lines.push("## Field Mapping"); lines.push(""); lines.push("| snake_case | camelCase |"); lines.push("|------------|-----------|"); for (const [snake, camel] of Object.entries(contract.fieldMapping.snakeToCamel)) { if (snake !== camel) { lines.push(`| \`${snake}\` | \`${camel}\` |`); } } lines.push(""); const report = lines.join("\n"); // Write report const reportPath = resolve(WORKSPACE, "contract-consistency-report.md"); mkdirSync(dirname(reportPath), { recursive: true }); writeFileSync(reportPath, report); console.log(`\nReport written to: ${reportPath}`); return report; } async function main() { const args = process.argv.slice(2); let projectDir = null, prdPath = null, archPath = null; for (let i = 0; i < args.length; i++) { if (args[i] === "--project" && args[i + 1]) { projectDir = resolve(args[++i]); } else if (args[i] === "--prd" && args[i + 1]) { prdPath = resolve(args[++i]); } else if (args[i] === "--arch" && args[i + 1]) { archPath = resolve(args[++i]); } else if (args[i] === "--help" || args[i] === "-h") { console.log(` Contract Consistency Test Usage: node scripts/contract-consistency-test.mjs --project [--prd ] [--arch ] Options: --project 项目根目录 --prd PRD JSON 文件路径 --arch Architecture JSON 文件路径 --help 显示帮助 `); process.exit(0); } } if (!projectDir) { console.error("Error: --project is required. Use --help."); process.exit(1); } // Set global PROJECT_DIR for check functions process.env.PROJECT_DIR = projectDir; // Load PRD and Architecture if available, or create a minimal contract let prd = null, arch = null; if (prdPath && existsSync(prdPath)) { try { prd = JSON.parse(readFileSync(prdPath, "utf-8")); } catch (e) { console.error(`Warning: Failed to load PRD: ${e.message}`); } } if (archPath && existsSync(archPath)) { try { arch = JSON.parse(readFileSync(archPath, "utf-8")); } catch (e) { console.error(`Warning: Failed to load Architecture: ${e.message}`); } } // If no PRD/arch, try to derive contract from generated project if (!prd && !arch) { // Check if project has a prd.json or arch.json const projectPrd = resolve(projectDir, "prd.json"); const projectArch = resolve(projectDir, "arch.json"); if (existsSync(projectPrd)) { try { prd = JSON.parse(readFileSync(projectPrd, "utf-8")); } catch {} } if (existsSync(projectArch)) { try { arch = JSON.parse(readFileSync(projectArch, "utf-8")); } catch {} } } // If still no PRD, try to detect domain from project structure if (!prd) { prd = { projectName: "ContractMgmt", domain: "enterprise", features: [], pages: [], apiRequirements: [] }; } // Create contract const contract = createContract(prd, arch); console.log(`\n═══ Contract Consistency Test ═══`); console.log(`Project: ${contract.projectName}`); console.log(`Domain: ${contract.domain}`); console.log(`Entities: ${contract.entities.map(e => e.name).join(", ")}`); console.log(`Project Dir: ${projectDir}\n`); // Initialize test const test = new ContractConsistencyTest(contract); // Run checks try { checkSchemaConsistency(test); } catch (e) { test.fail("Schema", `Error: ${e.message}`); } try { checkTypesConsistency(test); } catch (e) { test.fail("Types", `Error: ${e.message}`); } try { checkRoutesConsistency(test); } catch (e) { test.fail("Routes", `Error: ${e.message}`); } try { checkServicesConsistency(test); } catch (e) { test.fail("Services", `Error: ${e.message}`); } try { checkTestsConsistency(test); } catch (e) { test.fail("Tests", `Error: ${e.message}`); } try { checkAuthConsistency(test); } catch (e) { test.fail("Auth", `Error: ${e.message}`); } // Generate report const report = generateReport(test, contract); // Print summary console.log(`\n═══ Results ═══`); console.log(`Total: ${test.checks.total} | Passed: ${test.checks.passed} | Failed: ${test.checks.failed} | Warnings: ${test.warnings.length}`); console.log(`Result: ${test.checks.failed === 0 ? "✅ PASS" : "❌ FAIL"}`); // Exit with appropriate code process.exit(test.checks.failed > 0 ? 1 : 0); } main();