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();
|
||
}
|