bookworm-smart-assistant/scripts/deterministic-quality-gate.js

259 lines
8.3 KiB
JavaScript
Raw Normal View History

#!/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();