🎉 init: 小龙的工作空间
This commit is contained in:
@@ -0,0 +1,776 @@
|
||||
#!/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 <dir> [--prd <path>] [--arch <path>]
|
||||
*
|
||||
* Options:
|
||||
* --project <dir> 项目根目录
|
||||
* --prd <path> PRD JSON 文件路径(用于生成 Contract)
|
||||
* --arch <path> 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 <dir> [--prd <path>] [--arch <path>]
|
||||
|
||||
Options:
|
||||
--project <dir> 项目根目录
|
||||
--prd <path> PRD JSON 文件路径
|
||||
--arch <path> Architecture JSON 文件路径
|
||||
--help 显示帮助
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectDir) {
|
||||
console.error("Error: --project <dir> 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();
|
||||
Reference in New Issue
Block a user