#!/usr/bin/env node /** * PostToolUse Hook: 构建结果追踪器 (Phase 3) * Matcher: Bash * * 触发: 匹配构建/测试命令 * 功能: 将构建结果记录到 debug/outcome-YYYY-MM-DD.jsonl * * 日志格式: { ts, command, outcome, errorHint, sessionId, skill, traceId } * Phase 2: errorHint 提取 + 成功率聚合 (outcome-aggregation.json) * Phase 3: 技能-结果关联 + 跨 hook 会话追踪 * * stdin: { tool_name: "Bash", tool_input: { command }, tool_result: { stdout, stderr, exitCode } } * 退出码: 0 (始终放行,PostToolUse 不阻断) * * Fail-open: 任何异常 → exit(0) */ const fs = require('fs'); const path = require('path'); const { safeAppendJsonl } = require('./lib/safe-append.js'); const readStdin = require('./lib/read-stdin.js'); // ─── 路径解析 ──────────────────────────────────────── let debugDir; try { const { PATHS } = require('../scripts/paths.config.js'); debugDir = PATHS.debugDir; } catch { debugDir = path.resolve(__dirname, '..', 'debug'); } // ─── Feature Flag 检查 ─────────────────────────────── try { const { isEnabled } = require('../scripts/feature-flags.js'); if (!isEnabled('build-outcome-tracker')) { process.exit(0); } } catch { // feature-flags 加载失败 → 视为关闭,放行 process.exit(0); } // ─── User Override 检查 ────────────────────────────── try { const { isChecksDisabled } = require('../scripts/user-overrides.js'); if (isChecksDisabled()) { process.exit(0); } } catch {} // ─── 构建/测试命令模式 ────────────────────────────── const BUILD_TEST_PATTERNS = [ /\bnpm\s+run\s+(build|test)\b/, /\bnpm\s+test\b/, /\bnpx\s+.*(?:build|test)\b/, /\byarn\s+(?:build|test)\b/, /\bpnpm\s+(?:run\s+)?(?:build|test)\b/, /\btsc\b/, /\bmake\b/, /\bcargo\s+(?:build|test)\b/, /\bgo\s+(?:build|test)\b/, /\bgcc\b/, /\bg\+\+\b/, /\bjavac\b/, /\bjest\b/, /\bvitest\b/, /\bmocha\b/, /\bpytest\b/, /\bdotnet\s+(?:build|test)\b/, /\bgradlew?\s+(?:build|test)\b/, /\bmvn\s+(?:compile|test|package|install)\b/, /\bwebpack\b/, /\bvite\s+build\b/, /\besbuild\b/, ]; function isBuildOrTestCommand(command) { if (!command || typeof command !== 'string') return false; return BUILD_TEST_PATTERNS.some(p => p.test(command)); } // ─── T02: 管道命令检测 + 已知测试命令回退 ────────────── const KNOWN_TEST_RUNNERS = [ /\bvitest\b/, /\bjest\b/, /\bpytest\b/, /\bmocha\b/, /\bcargo\s+test\b/, /\bgo\s+test\b/, /\bdotnet\s+test\b/, ]; /** * 检测管道命令并提取基础命令 * @param {string} command * @returns {{ isPipe: boolean, baseCommand: string, isKnownTestRunner: boolean }} */ function detectPipeline(command) { if (!command) return { isPipe: false, baseCommand: command, isKnownTestRunner: false }; const isPipe = /\|/.test(command); const baseCommand = isPipe ? command.split('|')[0].trim() : command; const isKnownTestRunner = KNOWN_TEST_RUNNERS.some(p => p.test(baseCommand)); return { isPipe, baseCommand, isKnownTestRunner }; } // ─── Outcome 推断 ──────────────────────────────────── const FAILURE_PATTERNS = [ /\berror\b/i, /\bfailed\b/i, /\bfailure\b/i, /\bERROR\b/, /\bFAILED\b/, /\bfatal\b/i, /\bexception\b/i, /\bsegfault\b/i, /exit\s+code\s+[1-9]/i, /\bnot\s+found\b/i, /\bcommand\s+failed\b/i, ]; const SUCCESS_PATTERNS = [ /\bsuccess\b/i, /\bpassed\b/i, /\bcompleted?\b/i, /\bbuilt?\s+successfully\b/i, /\bdone\b/i, /\ball\s+tests?\s+passed\b/i, /\b0\s+errors?\b/i, ]; // ─── P1: 测试框架汇总行检测 (优先于 exitCode,不受管道影响) ── const FRAMEWORK_RESULT_PATTERNS = [ // vitest/jest: "X failed |" 或 "Tests X passed" { pattern: /([1-9]\d*)\s+failed\s*[|\s]/i, success: false }, { pattern: /Tests?\s+(\d+)\s+passed/i, success: true }, // pytest: "X passed, Y failed" 或 "X passed in" { pattern: /(\d+)\s+passed,\s*(\d+)\s+failed/i, successFn: (m) => parseInt(m[2]) === 0 }, { pattern: /(\d+)\s+passed(?:\s+in\s+[\d.]+s)?$/m, success: true }, // cargo test { pattern: /test result:\s*ok/i, success: true }, { pattern: /test result:\s*FAILED/i, success: false }, // go test { pattern: /^PASS$/m, success: true }, { pattern: /^FAIL\b/m, success: false }, // tsc/build { pattern: /compiled?\s+successfully/i, success: true }, { pattern: /build\s+succeeded/i, success: true }, { pattern: /build\s+failed/i, success: false }, ]; /** * 从输出尾部检测测试框架汇总行 * @param {string} text - 合并后的输出文本 * @returns {'success'|'failure'|null} */ function detectFrameworkResult(text) { if (!text) return null; const lastLines = text.split('\n').slice(-30).join('\n'); for (const { pattern, success, successFn } of FRAMEWORK_RESULT_PATTERNS) { const match = lastLines.match(pattern); if (match) { return (successFn ? successFn(match) : success) ? 'success' : 'failure'; } } return null; } function inferOutcome(toolResult) { if (!toolResult) return 'unknown'; // 组合输出文本 const text = [toolResult.stdout || '', toolResult.stderr || '', toolResult.content || ''].join('\n'); // P1: 优先检测测试框架汇总行 (不受管道 exitCode 影响) const frameworkResult = detectFrameworkResult(text); if (frameworkResult) return frameworkResult; // exitCode: 字符串形式也接受 const exitCode = typeof toolResult.exitCode === 'number' ? toolResult.exitCode : typeof toolResult.exitCode === 'string' ? parseInt(toolResult.exitCode, 10) : null; // 非零退出码 → 可靠的失败信号 if (exitCode !== null && !isNaN(exitCode) && exitCode !== 0) return 'failure'; // 通用模式匹配 const hasFailure = FAILURE_PATTERNS.some(p => p.test(text)); const hasSuccess = SUCCESS_PATTERNS.some(p => p.test(text)); if (hasFailure && !hasSuccess) return 'failure'; if (hasSuccess && !hasFailure) return 'success'; if (hasFailure && hasSuccess) return 'failure'; // 有错误优先视为失败 // exitCode 0 + 无内容信号 → 成功 if (exitCode === 0) return 'success'; return 'unknown'; } // ─── Phase 2: errorHint 提取 ───────────────────────── /** * 从 tool_result 中提取第一行含 error/fail/fatal 的文本作为 errorHint * @param {Object} toolResult * @returns {string} errorHint (≤150 chars) 或空字符串 */ function extractErrorHint(toolResult) { if (!toolResult) return ''; const text = [toolResult.stderr || '', toolResult.stdout || '', toolResult.content || ''].join('\n'); const lines = text.split('\n'); for (const line of lines) { if (/\b(?:error|fail|fatal)\b/i.test(line) && line.trim().length > 0) { return line.trim().slice(0, 150); } } return ''; } // ─── Phase 2: 成功率聚合 ───────────────────────────── const AGGREGATION_FILE_PATH = path.join(debugDir, 'outcome-aggregation.json'); const MAX_AGGREGATION_COMMANDS = 50; /** * 更新成功率聚合数据 * @param {string} command - 构建命令 * @param {string} outcome - success|failure|unknown */ /** * P2-FIX: 命令规范化 — 去除 tail/head 参数差异和路径格式差异 * 减少 near-duplicate key (如 tail -5 vs tail -15) */ function normalizeCommand(cmd) { return (cmd || '') .replace(/\|\s*(tail|head)\s+-\d+/g, '') // 去除 tail -N / head -N .replace(/\\+/g, '/') // 统一路径分隔符 .replace(/\/\//g, '/') // 去除双斜杠 .trim() .slice(0, 150); // 截断到 150 字符 } function updateAggregation(command, outcome) { // H9: O_EXCL 文件锁保护 read-modify-write 操作 const lockFile = AGGREGATION_FILE_PATH + '.lock'; let lockFd; try { lockFd = fs.openSync(lockFile, 'wx'); } catch { return; } try { let agg = {}; if (fs.existsSync(AGGREGATION_FILE_PATH)) { agg = JSON.parse(fs.readFileSync(AGGREGATION_FILE_PATH, 'utf8')); } const cmdKey = normalizeCommand(command); if (!agg[cmdKey]) { agg[cmdKey] = { total: 0, success: 0, failure: 0, unknown: 0, lastUpdated: '' }; } agg[cmdKey].total++; if (outcome === 'success') agg[cmdKey].success++; else if (outcome === 'failure') agg[cmdKey].failure++; else agg[cmdKey].unknown++; agg[cmdKey].lastUpdated = new Date().toISOString(); // LRU 淘汰: 超过上限时移除最旧条目 const keys = Object.keys(agg); if (keys.length > MAX_AGGREGATION_COMMANDS) { const sorted = keys.sort((a, b) => { const ta = agg[a].lastUpdated || ''; const tb = agg[b].lastUpdated || ''; return ta.localeCompare(tb); }); // 删除最旧的条目直到达到上限 const toRemove = sorted.slice(0, keys.length - MAX_AGGREGATION_COMMANDS); for (const k of toRemove) { delete agg[k]; } } if (!fs.existsSync(debugDir)) fs.mkdirSync(debugDir, { recursive: true }); // P2: temp+rename 原子写入,防止并发半写 const _aggTmp = AGGREGATION_FILE_PATH + '.tmp.' + process.pid; fs.writeFileSync(_aggTmp, JSON.stringify(agg, null, 2) + '\n'); fs.renameSync(_aggTmp, AGGREGATION_FILE_PATH); } catch {} // H9: 释放锁 try { fs.closeSync(lockFd); fs.unlinkSync(lockFile); } catch {} } // ─── Phase 1: 技能归因 ─────────────────────────────── function getRouteSkill() { try { const routeStateFile = path.join(debugDir, 'route-state-current.json'); if (fs.existsSync(routeStateFile)) { const state = JSON.parse(fs.readFileSync(routeStateFile, 'utf8')); return (state.routing && state.routing.primary) || state.skill || 'unknown'; } } catch {} return 'unknown'; } function generateTraceId() { const ts = Date.now().toString(36); const rand = Math.random().toString(36).slice(2, 8); return `${ts}-${rand}`; } // ─── Phase 3: 技能-结果关联 (D3) ───────────────────── const SKILL_CORRELATION_FILE = path.join(debugDir, 'skill-outcome-correlation.json'); const MAX_SKILLS = 30; const RECENT_WINDOW_SIZE = 20; /** * 计算趋势: 前半 vs 后半成功率 * @param {Array} recentWindow - 最近的 outcome 列表 * @returns {'improving'|'worsening'|'stable'|'insufficient'} */ function computeTrend(recentWindow) { if (!recentWindow || recentWindow.length < 6) return 'insufficient'; const mid = Math.floor(recentWindow.length / 2); const firstHalf = recentWindow.slice(0, mid); const secondHalf = recentWindow.slice(mid); const rate = (arr) => arr.filter(o => o === 'success').length / arr.length; const firstRate = rate(firstHalf); const secondRate = rate(secondHalf); const diff = secondRate - firstRate; if (diff > 0.15) return 'improving'; if (diff < -0.15) return 'worsening'; return 'stable'; } /** * 更新技能-结果关联数据 * @param {string} skill - 技能名称 * @param {string} outcome - success|failure|unknown */ function updateSkillCorrelation(skill, outcome) { try { if (!skill || skill === 'unknown') return; if (!fs.existsSync(debugDir)) fs.mkdirSync(debugDir, { recursive: true }); let data = { skills: {}, generatedAt: '' }; try { if (fs.existsSync(SKILL_CORRELATION_FILE)) { data = JSON.parse(fs.readFileSync(SKILL_CORRELATION_FILE, 'utf8')); } } catch {} if (!data.skills) data.skills = {}; if (!data.skills[skill]) { data.skills[skill] = { total: 0, success: 0, failure: 0, unknown: 0, successRate: 0, lastUpdated: '', recentWindow: [] }; } const entry = data.skills[skill]; entry.total++; if (outcome === 'success') entry.success++; else if (outcome === 'failure') entry.failure++; else entry.unknown++; entry.successRate = entry.total > 0 ? Math.round((entry.success / entry.total) * 1000) / 1000 : 0; entry.lastUpdated = new Date().toISOString(); // recentWindow: 保留最近 RECENT_WINDOW_SIZE 条 if (!entry.recentWindow) entry.recentWindow = []; entry.recentWindow.push(outcome); if (entry.recentWindow.length > RECENT_WINDOW_SIZE) { entry.recentWindow = entry.recentWindow.slice(-RECENT_WINDOW_SIZE); } // LRU 淘汰: 超过 MAX_SKILLS 时移除最旧 const skillKeys = Object.keys(data.skills); if (skillKeys.length > MAX_SKILLS) { const sorted = skillKeys.sort((a, b) => { const ta = data.skills[a].lastUpdated || ''; const tb = data.skills[b].lastUpdated || ''; return ta.localeCompare(tb); }); const toRemove = sorted.slice(0, skillKeys.length - MAX_SKILLS); for (const k of toRemove) delete data.skills[k]; } data.generatedAt = new Date().toISOString(); // P2: temp+rename 原子写入,防止并发半写 const _corrTmp = SKILL_CORRELATION_FILE + '.tmp.' + process.pid; fs.writeFileSync(_corrTmp, JSON.stringify(data, null, 2) + '\n'); fs.renameSync(_corrTmp, SKILL_CORRELATION_FILE); } catch {} } /** * 获取技能的成功率和趋势 (导出供外部消费) * @param {string} skill - 技能名称 * @returns {{ successRate: number, total: number, trend: string }|null} */ function getSkillSuccessRate(skill) { try { if (!fs.existsSync(SKILL_CORRELATION_FILE)) return null; const data = JSON.parse(fs.readFileSync(SKILL_CORRELATION_FILE, 'utf8')); const entry = data.skills && data.skills[skill]; if (!entry || entry.total < 3) return null; return { successRate: entry.successRate, total: entry.total, trend: computeTrend(entry.recentWindow), }; } catch { return null; } } // ─── 日志写入 ──────────────────────────────────────── function logOutcome(entry) { try { const dateStr = new Date().toISOString().slice(0, 10); const logFile = path.join(debugDir, `outcome-${dateStr}.jsonl`); safeAppendJsonl(logFile, entry); } catch {} } // ─── 主流程 ────────────────────────────────────────── function main() { readStdin({ maxSize: 512 * 1024 }).then(input => { const command = input.tool_input?.command; // 非构建/测试命令 → 跳过 if (!isBuildOrTestCommand(command)) { process.exit(0); return; } // 推断结果 let outcome = inferOutcome(input.tool_result); // T02: 管道命令二次判定 — 已知测试命令 + unknown → 内容无失败关键词则视为 success if (outcome === 'unknown') { const pipe = detectPipeline(command); if (pipe.isKnownTestRunner) { const text = [ (input.tool_result?.stdout || ''), (input.tool_result?.stderr || ''), (input.tool_result?.content || '') ].join('\n'); const hasFail = /\bfail|\berror|\bFAIL|\bERROR/i.test(text); if (!hasFail) { outcome = 'success'; } } } // Phase 2: errorHint 提取(仅失败时) const errorHint = outcome === 'failure' ? extractErrorHint(input.tool_result) : ''; // Phase 1: 技能归因 const skill = getRouteSkill(); // Phase 3: 使用共享 traceId (D5) let traceId; try { const { getSessionTrace } = require('../scripts/session-trace.js'); traceId = getSessionTrace().traceId; } catch { traceId = generateTraceId(); } // 记录(Phase 2: 增加 errorHint 字段) const cmdNormalized = (command || '').slice(0, 200); // T02: 管道检测 const pipeline = detectPipeline(command); logOutcome({ ts: new Date().toISOString(), command: cmdNormalized, outcome, pipelineMode: pipeline.isPipe, errorHint, sessionId: input.session_id || 'unknown', skill, traceId, }); // Phase 2: 更新聚合数据 updateAggregation(cmdNormalized, outcome); // Phase 3: 技能-结果关联 (D3) try { updateSkillCorrelation(skill, outcome); } catch {} // Phase 3: 跨 hook 会话追踪 (D5) try { const { appendTraceEvent } = require('../scripts/session-trace.js'); appendTraceEvent('build-outcome-tracker', 'outcome', { command: cmdNormalized, outcome, skill, errorHint: errorHint ? errorHint.slice(0, 80) : '', }); } catch {} process.exit(0); }).catch(() => process.exit(0)); } // 模块导出 (供测试) if (typeof module !== 'undefined') { module.exports = { isBuildOrTestCommand, inferOutcome, detectFrameworkResult, detectPipeline, extractErrorHint, updateAggregation, getRouteSkill, generateTraceId, AGGREGATION_FILE_PATH, updateSkillCorrelation, getSkillSuccessRate, computeTrend }; } if (require.main === module) { main(); }