505 lines
17 KiB
JavaScript
505 lines
17 KiB
JavaScript
#!/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<string>} 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();
|
||
}
|