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