#!/usr/bin/env node /** * B4: 确定性质量门控 — 叠加在 AI quality-gate Agent 之上 * * 所有检查基于退出码/正则匹配,不依赖 LLM 判定,保证确定性结果。 * 供 loop-controller.sh 在 AI 门控之前调用。 * * 用法: * node deterministic-quality-gate.js [--project-dir /path/to/project] * * 退出码: * 0 = PASS (所有确定性检查通过) * 1 = BLOCKED (有确定性检查失败) * 2 = SKIP (无项目或无可检查内容) * * stdout: JSON 格式检查报告 */ const { execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); // ─── 配置 ──────────────────────────────────────────────── const args = process.argv.slice(2); let projectDir = process.cwd(); const dirIdx = args.indexOf('--project-dir'); if (dirIdx !== -1 && args[dirIdx + 1]) { projectDir = args[dirIdx + 1]; } // B1 安全: 规范化 + 验证 projectDir 为真实已存在目录 projectDir = path.resolve(projectDir); try { const st = fs.lstatSync(projectDir); if (!st.isDirectory() || st.isSymbolicLink()) { process.stderr.write('[quality-gate] projectDir 非真实目录: ' + projectDir + '\n'); process.exit(2); } } catch { process.stderr.write('[quality-gate] projectDir 不存在: ' + projectDir + '\n'); process.exit(2); } const report = { timestamp: new Date().toISOString(), projectDir, checks: [], result: 'PASS', blockers: [], }; function addCheck(name, status, detail) { report.checks.push({ name, status, detail }); if (status === 'BLOCKED') { report.result = 'BLOCKED'; report.blockers.push(name + ': ' + detail); } } function runCmd(cmd, opts) { try { const output = execSync(cmd, { cwd: projectDir, timeout: 120000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], ...opts, }); return { success: true, output: output.trim(), exitCode: 0 }; } catch (e) { return { success: false, output: (e.stdout || '').trim(), stderr: (e.stderr || '').trim(), exitCode: e.status || 1, }; } } // ─── D1: TypeScript 类型检查 ───────────────────────────── function checkTypeScript() { const tsconfig = path.join(projectDir, 'tsconfig.json'); if (!fs.existsSync(tsconfig)) { addCheck('D1-TypeScript', 'SKIP', '无 tsconfig.json'); return; } const result = runCmd('npx tsc --noEmit --pretty false 2>&1'); if (result.success) { addCheck('D1-TypeScript', 'PASS', '类型检查通过'); } else { // 统计错误数 const errorCount = (result.output.match(/error TS\d+/g) || []).length; addCheck('D1-TypeScript', 'BLOCKED', errorCount + ' 个类型错误'); } } // ─── D2: 依赖安全审计 ─────────────────────────────────── function checkDependencySecurity() { const lockFile = path.join(projectDir, 'pnpm-lock.yaml'); const npmLock = path.join(projectDir, 'package-lock.json'); if (!fs.existsSync(lockFile) && !fs.existsSync(npmLock)) { addCheck('D2-DepSecurity', 'SKIP', '无 lock 文件'); return; } const cmd = fs.existsSync(lockFile) ? 'pnpm audit --audit-level=high 2>&1' : 'npm audit --audit-level=high 2>&1'; const result = runCmd(cmd); if (result.success) { addCheck('D2-DepSecurity', 'PASS', '无 high/critical 漏洞'); } else { // 提取漏洞数 const match = result.output.match(/(\d+)\s+(?:high|critical)/i); const count = match ? match[1] : 'unknown'; addCheck('D2-DepSecurity', 'BLOCKED', count + ' 个高危漏洞'); } } // ─── D3: 测试通过 + 断言检测 ──────────────────────────── function checkTests() { const pkg = path.join(projectDir, 'package.json'); if (!fs.existsSync(pkg)) { addCheck('D3-Tests', 'SKIP', '无 package.json'); return; } let pkgJson; try { pkgJson = JSON.parse(fs.readFileSync(pkg, 'utf8')); } catch { return; } if (!pkgJson.scripts || !pkgJson.scripts.test) { addCheck('D3-Tests', 'SKIP', '无 test 脚本'); return; } // 运行测试 const result = runCmd('pnpm test 2>&1', { timeout: 300000 }); if (!result.success) { addCheck('D3-Tests', 'BLOCKED', '测试失败 (exit ' + result.exitCode + ')'); return; } // 检测空断言测试 (测试文件中无 expect/assert) const testDirs = ['__tests__', 'test', 'tests', 'spec']; let emptyTestCount = 0; for (const dir of testDirs) { const testDir = path.join(projectDir, dir); if (!fs.existsSync(testDir)) continue; const files = findTestFiles(testDir); for (const file of files) { const content = fs.readFileSync(file, 'utf8'); // 检测 test/it 块中是否有 expect/assert/should const hasTestBlock = /\b(?:test|it)\s*\(/.test(content); const hasAssertion = /\b(?:expect|assert|should|toBe|toEqual|toThrow|toMatch|toContain)\b/.test(content); if (hasTestBlock && !hasAssertion) { emptyTestCount++; } } } if (emptyTestCount > 0) { addCheck('D3-Tests', 'BLOCKED', emptyTestCount + ' 个测试文件缺少断言 (空测试)'); } else { addCheck('D3-Tests', 'PASS', '测试通过'); } } // LV-N1 修复: 加深度限制 + symlink 跳过,防止循环递归 function findTestFiles(dir, depth) { if (depth === undefined) depth = 0; if (depth > 10) return []; const results = []; try { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.isSymbolicLink()) continue; const full = path.join(dir, entry.name); if (entry.isDirectory() && entry.name !== 'node_modules') { results.push(...findTestFiles(full, depth + 1)); } else if (entry.isFile() && /\.(test|spec)\.(js|ts|jsx|tsx)$/.test(entry.name)) { results.push(full); } } } catch {} return results; } // ─── D4: 构建成功 ─────────────────────────────────────── function checkBuild() { const pkg = path.join(projectDir, 'package.json'); if (!fs.existsSync(pkg)) { addCheck('D4-Build', 'SKIP', '无 package.json'); return; } let pkgJson; try { pkgJson = JSON.parse(fs.readFileSync(pkg, 'utf8')); } catch { return; } if (!pkgJson.scripts || !pkgJson.scripts.build) { addCheck('D4-Build', 'SKIP', '无 build 脚本'); return; } const result = runCmd('pnpm build 2>&1', { timeout: 300000 }); if (result.success) { addCheck('D4-Build', 'PASS', '构建成功'); } else { addCheck('D4-Build', 'BLOCKED', '构建失败 (exit ' + result.exitCode + ')'); } } // ─── D5: BLOCKED 循环检测 (防振荡) ────────────────────── function checkBlockedPattern() { // 读取 loop-state 中的历史质量结果 const loopStateFile = path.join( process.env.CLAUDE_HOME || path.join(require('os').homedir(), '.claude'), 'loop-state', 'loop-state.json' ); if (!fs.existsSync(loopStateFile)) return; try { const state = JSON.parse(fs.readFileSync(loopStateFile, 'utf8')); const results = state.qualityResults || []; // 检测连续 3 轮相同 BLOCKED 原因 if (results.length >= 3) { const last3 = results.slice(-3); const allBlocked = last3.every(r => r.result === 'BLOCKED'); if (allBlocked) { // 检查是否同类原因 const reasons = last3.map(r => r.blockers ? r.blockers.sort().join(';') : ''); const allSame = reasons[0] === reasons[1] && reasons[1] === reasons[2]; if (allSame && reasons[0]) { addCheck('D5-StuckDetection', 'BLOCKED', '连续 3 轮因相同原因 BLOCKED (' + reasons[0].slice(0, 100) + '),疑似修复振荡'); } } } } catch {} } // ─── 主流程 ────────────────────────────────────────────── function main() { checkTypeScript(); checkDependencySecurity(); checkTests(); checkBuild(); checkBlockedPattern(); // 输出报告 process.stdout.write(JSON.stringify(report, null, 2) + '\n'); // 退出码 process.exit(report.result === 'PASS' ? 0 : 1); } main();