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

321 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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