bookworm-smart-assistant/hooks/code-quality-gate.js

321 lines
12 KiB
JavaScript
Raw Normal View History

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