321 lines
12 KiB
JavaScript
321 lines
12 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
/**
|
|||
|
|
* PreToolUse Hook: 构建命令质量门控 (Phase 3)
|
|||
|
|
* Matcher: Bash
|
|||
|
|
*
|
|||
|
|
* 触发: 仅匹配构建命令 (npm build/tsc/make/cargo/go build 等)
|
|||
|
|
* 非构建命令: 立即 exit(0) 放行 (<10ms)
|
|||
|
|
*
|
|||
|
|
* Phase 1: 连续失败警告 (warn 模式)
|
|||
|
|
* Phase 2: enforce 阻断 (exit(2)) + /force 逃生舱 + 错误分类
|
|||
|
|
* Phase 3: 自适应构建阈值 + 跨 hook 会话追踪
|
|||
|
|
*
|
|||
|
|
* stdin: { tool_name: "Bash", tool_input: { command } }
|
|||
|
|
* 退出码: 0=放行, 2=阻断 (enforce 模式)
|
|||
|
|
*
|
|||
|
|
* Fail-open: 任何异常 → exit(0) 放行
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
|
|||
|
|
const readStdin = require('./lib/read-stdin.js');
|
|||
|
|
|
|||
|
|
// ─── Feature Flag 检查 (延迟到运行时,避免 require 时 process.exit) ─
|
|||
|
|
let _featureEnabled = null; // null=未检查, true/false=已检查
|
|||
|
|
function isFeatureEnabled() {
|
|||
|
|
if (_featureEnabled !== null) return _featureEnabled;
|
|||
|
|
try {
|
|||
|
|
const { isEnabled } = require('../scripts/feature-flags.js');
|
|||
|
|
_featureEnabled = !!isEnabled('code-quality-gate');
|
|||
|
|
} catch {
|
|||
|
|
_featureEnabled = false; // feature-flags 加载失败 → 视为关闭
|
|||
|
|
}
|
|||
|
|
return _featureEnabled;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── User Override 检查 (延迟) ──────────────────────
|
|||
|
|
function isUserOverrideDisabled() {
|
|||
|
|
try {
|
|||
|
|
const { isChecksDisabled } = require('../scripts/user-overrides.js');
|
|||
|
|
return isChecksDisabled();
|
|||
|
|
} catch { return false; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── 路径解析 ────────────────────────────────────────
|
|||
|
|
let debugDir;
|
|||
|
|
try {
|
|||
|
|
const { PATHS } = require('../scripts/paths.config.js');
|
|||
|
|
debugDir = PATHS.debugDir;
|
|||
|
|
} catch {
|
|||
|
|
debugDir = path.resolve(__dirname, '..', 'debug');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Mode 检查 (延迟) ──────────────────────────────
|
|||
|
|
function getCurrentMode() {
|
|||
|
|
try {
|
|||
|
|
const { getMode } = require('../scripts/feature-flags.js');
|
|||
|
|
return getMode('code-quality-gate') || 'warn';
|
|||
|
|
} catch { return 'warn'; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Phase 2: /force 逃生舱 (延迟) ─────────────────
|
|||
|
|
function checkForceActive() {
|
|||
|
|
try {
|
|||
|
|
const { isForceActive, clearForce } = require('../scripts/user-overrides.js');
|
|||
|
|
const forceState = isForceActive();
|
|||
|
|
if (forceState.active) {
|
|||
|
|
clearForce(); // 单次生效
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 兼容变量 (供 main 函数中原有引用使用)
|
|||
|
|
let currentMode = 'warn';
|
|||
|
|
let forceActive = false;
|
|||
|
|
|
|||
|
|
// ─── Phase 2: 错误分类器 ────────────────────────────
|
|||
|
|
const ERROR_CATEGORIES = {
|
|||
|
|
'type': { label: '类型错误', fix: '检查类型标注,确认变量类型和函数签名' },
|
|||
|
|
'syntax': { label: '语法错误', fix: '检查括号/引号/分号是否匹配' },
|
|||
|
|
'module': { label: '模块错误', fix: '确认导入路径和模块是否存在' },
|
|||
|
|
'test': { label: '测试失败', fix: '查看失败用例,修复断言或逻辑' },
|
|||
|
|
'build-config': { label: '构建配置', fix: '检查 tsconfig/webpack/vite 配置' },
|
|||
|
|
'unknown': { label: '未知错误', fix: '查看完整输出定位问题' },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function categorizeErrors(command, outcomes) {
|
|||
|
|
// 从 outcomes 中提取 errorHint,结合 command 文本分类
|
|||
|
|
const hints = outcomes.map(o => (o.errorHint || '') + ' ' + (command || '')).join(' ').toLowerCase();
|
|||
|
|
|
|||
|
|
if (/type\s*error|is not assignable|has no property/i.test(hints)) return ERROR_CATEGORIES['type'];
|
|||
|
|
if (/syntax\s*error|unexpected token|unterminated/i.test(hints)) return ERROR_CATEGORIES['syntax'];
|
|||
|
|
if (/cannot find module|module not found|no such file/i.test(hints)) return ERROR_CATEGORIES['module'];
|
|||
|
|
if (/test.*fail|assert|expect.*to/i.test(hints) || /\b(jest|vitest|mocha|pytest)\b/.test(command || '')) return ERROR_CATEGORIES['test'];
|
|||
|
|
if (/tsconfig|webpack|vite\.config|rollup\.config/i.test(hints)) return ERROR_CATEGORIES['build-config'];
|
|||
|
|
return ERROR_CATEGORIES['unknown'];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Phase 1+2: 构建历史查询 ────────────────────────
|
|||
|
|
/**
|
|||
|
|
* 读取 outcome 日志,返回同命令最近 N 条记录
|
|||
|
|
* @param {string} command - 构建命令
|
|||
|
|
* @param {number} limit - 最多返回条数
|
|||
|
|
* @returns {Array<{outcome: string}>}
|
|||
|
|
*/
|
|||
|
|
function getRecentOutcomes(command, limit) {
|
|||
|
|
const results = [];
|
|||
|
|
const cmdNormalized = (command || '').slice(0, 200);
|
|||
|
|
|
|||
|
|
// Phase 2: 扫描今天 + 昨天的文件(解决凌晨边界问题)
|
|||
|
|
const today = new Date();
|
|||
|
|
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
|||
|
|
const dateStrs = [yesterday.toISOString().slice(0, 10), today.toISOString().slice(0, 10)];
|
|||
|
|
|
|||
|
|
for (const dateStr of dateStrs) {
|
|||
|
|
const logFile = path.join(debugDir, `outcome-${dateStr}.jsonl`);
|
|||
|
|
if (!fs.existsSync(logFile)) continue;
|
|||
|
|
|
|||
|
|
const lines = fs.readFileSync(logFile, 'utf8').trim().split('\n');
|
|||
|
|
for (const line of lines) {
|
|||
|
|
try {
|
|||
|
|
const entry = JSON.parse(line);
|
|||
|
|
if (entry.command === cmdNormalized) {
|
|||
|
|
results.push(entry);
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return results.slice(-limit);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── 构建命令模式 ────────────────────────────────────
|
|||
|
|
const BUILD_PATTERNS = [
|
|||
|
|
/\bnpm\s+run\s+build\b/,
|
|||
|
|
/\bnpx\s+.*build\b/,
|
|||
|
|
/\byarn\s+build\b/,
|
|||
|
|
/\bpnpm\s+(?:run\s+)?build\b/,
|
|||
|
|
/\btsc\b/,
|
|||
|
|
/\bmake\b/,
|
|||
|
|
/\bcargo\s+build\b/,
|
|||
|
|
/\bgo\s+build\b/,
|
|||
|
|
/\bgcc\b/,
|
|||
|
|
/\bg\+\+\b/,
|
|||
|
|
/\bjavac\b/,
|
|||
|
|
/\bmsbuild\b/,
|
|||
|
|
/\bdotnet\s+build\b/,
|
|||
|
|
/\bgradlew?\s+build\b/,
|
|||
|
|
/\bmvn\s+(?:compile|package|install)\b/,
|
|||
|
|
/\bwebpack\b/,
|
|||
|
|
/\bvite\s+build\b/,
|
|||
|
|
/\brollup\b/,
|
|||
|
|
/\besbuild\b/,
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
function isBuildCommand(command) {
|
|||
|
|
if (!command || typeof command !== 'string') return false;
|
|||
|
|
return BUILD_PATTERNS.some(p => p.test(command));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Phase 3: 自适应构建阈值 (D2) ───────────────────
|
|||
|
|
const AGGREGATION_FILE = path.join(debugDir, 'outcome-aggregation.json');
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 基于历史成功率动态调整连续失败阈值
|
|||
|
|
* @param {string} command - 构建命令
|
|||
|
|
* @returns {number} 阈值 (2-4)
|
|||
|
|
*/
|
|||
|
|
function getAdaptiveThreshold(command) {
|
|||
|
|
try {
|
|||
|
|
if (!fs.existsSync(AGGREGATION_FILE)) return 3;
|
|||
|
|
const agg = JSON.parse(fs.readFileSync(AGGREGATION_FILE, 'utf8'));
|
|||
|
|
const cmdKey = (command || '').slice(0, 200);
|
|||
|
|
const entry = agg[cmdKey];
|
|||
|
|
if (!entry || (entry.total || 0) < 5) return 3; // 数据不足 → 默认
|
|||
|
|
|
|||
|
|
const successRate = entry.total > 0 ? entry.success / entry.total : 0;
|
|||
|
|
if (successRate >= 0.8) return 4; // 宽松
|
|||
|
|
if (successRate >= 0.5) return 3; // 默认
|
|||
|
|
return 2; // 严格
|
|||
|
|
} catch {
|
|||
|
|
return 3;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 可导出的构建检查函数 (供 dispatcher 调用)
|
|||
|
|
* @param {string} command - bash 命令
|
|||
|
|
* @param {object} input - 完整的 hook stdin 输入
|
|||
|
|
* @returns {object|null} 检查结果,null 表示放行
|
|||
|
|
* { decision: 'deny'|'warn', message: string }
|
|||
|
|
*/
|
|||
|
|
function checkBuild(command, input) {
|
|||
|
|
// 前置检查: feature flag + user override
|
|||
|
|
if (!isFeatureEnabled() || isUserOverrideDisabled()) return null;
|
|||
|
|
if (!isBuildCommand(command)) return null;
|
|||
|
|
|
|||
|
|
const mode = getCurrentMode();
|
|||
|
|
const force = checkForceActive();
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const threshold = getAdaptiveThreshold(command);
|
|||
|
|
const recentOutcomes = getRecentOutcomes(command, threshold + 2);
|
|||
|
|
const lastN = recentOutcomes.slice(-threshold);
|
|||
|
|
const hasConsecutiveFailures = lastN.length >= threshold && lastN.every(r => r.outcome === 'failure');
|
|||
|
|
|
|||
|
|
if (hasConsecutiveFailures) {
|
|||
|
|
const category = categorizeErrors(command, lastN);
|
|||
|
|
const categoryInfo = `\n分类: ${category.label}\n建议: ${category.fix}`;
|
|||
|
|
|
|||
|
|
// Phase 3: 跨 hook 会话追踪 (D5)
|
|||
|
|
try {
|
|||
|
|
const { appendTraceEvent } = require('../scripts/session-trace.js');
|
|||
|
|
appendTraceEvent('code-quality-gate', mode === 'enforce' && !force ? 'block-warn' : 'pass', {
|
|||
|
|
command: (command || '').slice(0, 100),
|
|||
|
|
threshold,
|
|||
|
|
consecutiveFailures: threshold,
|
|||
|
|
category: category.label,
|
|||
|
|
});
|
|||
|
|
} catch {}
|
|||
|
|
|
|||
|
|
if (mode === 'enforce' && !force) {
|
|||
|
|
return {
|
|||
|
|
decision: 'deny',
|
|||
|
|
message: `构建阻断 -- 最近 ${threshold} 次连续失败 (自适应阈值),请先修复错误。\n命令: ${(command || '').slice(0, 100)}${categoryInfo}\n(enforce 模式,使用 /force 可单次绕过)`,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const modeNote = force ? '/force 已激活,绕过阻断' : 'warn 模式,仅提醒不阻断';
|
|||
|
|
return {
|
|||
|
|
decision: 'warn',
|
|||
|
|
message: `最近 ${threshold} 次构建失败 (自适应阈值),建议先修复错误再重试\n命令: ${(command || '').slice(0, 100)}${categoryInfo}\n(${modeNote})`,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
|
|||
|
|
return null; // 放行
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── 主流程 ──────────────────────────────────────────
|
|||
|
|
function main() {
|
|||
|
|
// 早退: feature flag / user override (仅独立运行时)
|
|||
|
|
if (!isFeatureEnabled() || isUserOverrideDisabled()) {
|
|||
|
|
process.exit(0);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
currentMode = getCurrentMode();
|
|||
|
|
forceActive = checkForceActive();
|
|||
|
|
|
|||
|
|
readStdin({ maxSize: 128 * 1024 }).then(input => {
|
|||
|
|
const command = input.tool_input?.command;
|
|||
|
|
|
|||
|
|
// 非构建命令 → 立即放行
|
|||
|
|
if (!isBuildCommand(command)) {
|
|||
|
|
process.exit(0);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Phase 1+2+3: 连续失败检测 (自适应阈值) ─────
|
|||
|
|
try {
|
|||
|
|
const threshold = getAdaptiveThreshold(command);
|
|||
|
|
const recentOutcomes = getRecentOutcomes(command, threshold + 2);
|
|||
|
|
const lastN = recentOutcomes.slice(-threshold);
|
|||
|
|
const hasConsecutiveFailures = lastN.length >= threshold && lastN.every(r => r.outcome === 'failure');
|
|||
|
|
|
|||
|
|
if (hasConsecutiveFailures) {
|
|||
|
|
// Phase 2: 错误分类
|
|||
|
|
const category = categorizeErrors(command, lastN);
|
|||
|
|
const categoryInfo = `\n分类: ${category.label}\n建议: ${category.fix}`;
|
|||
|
|
|
|||
|
|
// Phase 3: 跨 hook 会话追踪 (D5)
|
|||
|
|
try {
|
|||
|
|
const { appendTraceEvent } = require('../scripts/session-trace.js');
|
|||
|
|
appendTraceEvent('code-quality-gate', currentMode === 'enforce' && !forceActive ? 'block-warn' : 'pass', {
|
|||
|
|
command: (command || '').slice(0, 100),
|
|||
|
|
threshold,
|
|||
|
|
consecutiveFailures: threshold,
|
|||
|
|
category: category.label,
|
|||
|
|
});
|
|||
|
|
} catch {}
|
|||
|
|
|
|||
|
|
if (currentMode === 'enforce' && !forceActive) {
|
|||
|
|
// enforce 模式: 实际阻断
|
|||
|
|
const result = {
|
|||
|
|
hookSpecificOutput: { permissionDecision: 'deny' },
|
|||
|
|
systemMessage: `构建阻断 -- 最近 ${threshold} 次连续失败 (自适应阈值),请先修复错误。\n命令: ${(command || '').slice(0, 100)}${categoryInfo}\n(enforce 模式,使用 /force 可单次绕过)`,
|
|||
|
|
};
|
|||
|
|
process.stderr.write(JSON.stringify(result));
|
|||
|
|
process.exit(2);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// warn 模式 或 forceActive: 放行但提醒
|
|||
|
|
const modeNote = forceActive ? '/force 已激活,绕过阻断' : 'warn 模式,仅提醒不阻断';
|
|||
|
|
const result = {
|
|||
|
|
continue: true,
|
|||
|
|
systemMessage: `最近 ${threshold} 次构建失败 (自适应阈值),建议先修复错误再重试\n命令: ${(command || '').slice(0, 100)}${categoryInfo}\n(${modeNote})`,
|
|||
|
|
};
|
|||
|
|
process.stdout.write(JSON.stringify(result));
|
|||
|
|
process.exit(0);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
// ────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
// 无连续失败,放行
|
|||
|
|
process.exit(0);
|
|||
|
|
}).catch(() => process.exit(0));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 模块导出 (供测试和 dispatcher 使用)
|
|||
|
|
if (typeof module !== 'undefined') {
|
|||
|
|
module.exports = { isBuildCommand, getRecentOutcomes, categorizeErrors, ERROR_CATEGORIES, getAdaptiveThreshold, checkBuild };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (require.main === module) {
|
|||
|
|
main();
|
|||
|
|
}
|