259 lines
8.3 KiB
JavaScript
259 lines
8.3 KiB
JavaScript
|
|
#!/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();
|