bookworm-smart-assistant/hooks/post-edit-quality-check.js

374 lines
12 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* PostToolUse Hook: 编辑后质量检查 (Phase 3)
* Matcher: Edit|Write
*
* 触发: file_path 的编辑操作
*
* Phase 1: 轻量级反模式检测 (warn 模式)
* Phase 2: 5 新规则 + severity 分级 + enforce 模式 (强指令 systemMessage)
* Phase 3: 检测统计追踪 + hook 会话追踪
*
* stdin: { tool_name, tool_input: { file_path }, tool_result }
* 退出码: 0 (始终放行PostToolUse 不阻断)
*
* Fail-open: 任何异常 exit(0)
*/
const fs = require('fs');
const path = require('path');
const readStdin = require('./lib/read-stdin.js');
// ─── Feature Flag 检查 ───────────────────────────────
try {
const { isEnabled } = require('../scripts/feature-flags.js');
if (!isEnabled('post-edit-quality-check')) {
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 {}
// ─── Mode 检查 ──────────────────────────────────────
let currentMode = 'warn';
try {
const { getMode } = require('../scripts/feature-flags.js');
currentMode = getMode('post-edit-quality-check');
} catch {}
// ─── 路径解析 ────────────────────────────────────────
let debugDir;
try {
const { PATHS } = require('../scripts/paths.config.js');
debugDir = PATHS.debugDir;
} catch {
debugDir = path.resolve(__dirname, '..', 'debug');
}
// ─── Phase 1+2: 反模式检测规则 ──────────────────────────
const CODE_EXTENSIONS = /\.(?:js|ts|jsx|tsx)$/i;
const ANTI_PATTERNS = [
// Phase 1 规则 (severity 升级)
{
id: 'console-log',
label: '残留调试语句',
severity: 'warning',
pattern: /console\.(log|debug|info)\(/,
},
{
id: 'hardcoded-secret',
label: '硬编码密钥',
severity: 'error',
pattern: /(?:password|secret|api_?key|token)\s*[:=]\s*['"][^'"]{8,}/i,
},
{
id: 'sql-concat',
label: 'SQL 拼接注入风险',
severity: 'error',
pattern: /(?:SELECT|INSERT|UPDATE|DELETE).*\+\s*(?:req|user|input|param)/i,
},
{
id: 'unhandled-promise',
label: '未 catch 的 Promise',
severity: 'warning',
pattern: /(?:\.then\([^)]*\))\s*(?:$|[;,])\s*(?!\.catch)/,
},
// Phase 2 新增规则
{
id: 'eval-usage',
label: '不安全的 eval/Function',
severity: 'error',
pattern: /\b(?:eval|new\s+Function)\s*\(/,
},
{
id: 'innerhtml-assign',
label: 'XSS 风险 (innerHTML)',
severity: 'error',
pattern: /\.innerHTML\s*(?:=|\+=)/,
},
{
id: 'debug-debugger',
label: '残留 debugger',
severity: 'error',
pattern: /^\s*debugger\s*;?\s*$/,
},
{
id: 'todo-fixme',
label: '残留标记 (TODO/FIXME)',
severity: 'warning',
pattern: /\/[/*]\s*(?:TODO|FIXME|HACK|XXX)\b/,
},
{
id: 'no-any-type',
label: 'TS any 类型',
severity: 'warning',
pattern: /:\s*any\b/,
},
];
/**
* 扫描文本内容返回匹配的反模式列表
* @param {string} content - 文件内容
* @returns {Array<{id: string, label: string, severity: string, line: number}>}
*/
function detectAntiPatterns(content) {
if (!content || typeof content !== 'string') return [];
const findings = [];
const lines = content.split('\n');
// 文件级 eslint-disable 集合(/* eslint-disable rule-id */
const fileDisabled = new Set();
for (const line of lines) {
const m = line.match(/\/\*\s*eslint-disable\s+([\w,\s/-]+)\*\//);
if (m) {
m[1].split(',').map(s => s.trim()).filter(Boolean).forEach(r => fileDisabled.add(r));
}
// 无规则名的全局 disable/* eslint-disable */
if (/\/\*\s*eslint-disable\s*\*\//.test(line)) fileDisabled.add('*');
}
// console-log 规则对应的 eslint rule id
const RULE_ESLINT_ID = {
'console-log': 'no-console',
};
for (let i = 0; i < lines.length; i++) {
for (const rule of ANTI_PATTERNS) {
if (!rule.pattern.test(lines[i])) continue;
// 检查文件级 disable
const eslintId = RULE_ESLINT_ID[rule.id];
if (eslintId && (fileDisabled.has(eslintId) || fileDisabled.has('*'))) continue;
// 检查行级 eslint-disable-line / eslint-disable-next-line
const lineComment = lines[i];
if (eslintId) {
const lineDisable = new RegExp(`eslint-disable(?:-line|-next-line)?\\s+[\\w,\\s/-]*${eslintId}`);
if (lineDisable.test(lineComment)) continue;
}
findings.push({ id: rule.id, label: rule.label, severity: rule.severity || 'warning', line: i + 1 });
}
}
return findings;
}
/**
* tool_input/tool_result 中提取文件内容不读磁盘
*/
function extractContent(input) {
// Write tool: tool_input.content contains the full file content
if (input.tool_input?.content) return input.tool_input.content;
// Edit tool: tool_input.new_string contains the edited fragment
if (input.tool_input?.new_string) return input.tool_input.new_string;
// Fallback: tool_result may contain content
if (input.tool_result?.content) return input.tool_result.content;
return null;
}
// ─── Phase 3: 检测统计追踪 (D1) ─────────────────────
const DETECTION_STATS_FILE = path.join(debugDir, 'detection-stats.json');
/**
* 清理对象中超过 maxDays 天的 key (YYYY-MM-DD 格式)
*/
function pruneOldDays(obj, maxDays) {
if (!obj || typeof obj !== 'object') return obj;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - maxDays);
const cutoffStr = cutoff.toISOString().slice(0, 10);
for (const key of Object.keys(obj)) {
if (/^\d{4}-\d{2}-\d{2}$/.test(key) && key < cutoffStr) {
delete obj[key];
}
}
return obj;
}
/**
* 更新检测统计数据
* @param {Array} findings - detectAntiPatterns 返回的结果
* @param {string} filePath - 被检测的文件路径
*/
function updateDetectionStats(findings, filePath) {
try {
if (!findings || findings.length === 0) return;
if (!fs.existsSync(debugDir)) fs.mkdirSync(debugDir, { recursive: true });
let stats = { rules: {}, files: {}, dailyTotals: {}, lastUpdated: '' };
try {
if (fs.existsSync(DETECTION_STATS_FILE)) {
stats = JSON.parse(fs.readFileSync(DETECTION_STATS_FILE, 'utf8'));
}
} catch {}
const today = new Date().toISOString().slice(0, 10);
// per-rule 统计
if (!stats.rules) stats.rules = {};
for (const f of findings) {
if (!stats.rules[f.id]) {
stats.rules[f.id] = { total: 0, bySeverity: {}, last7days: {} };
}
const rule = stats.rules[f.id];
rule.total++;
rule.bySeverity[f.severity] = (rule.bySeverity[f.severity] || 0) + 1;
if (!rule.last7days) rule.last7days = {};
rule.last7days[today] = (rule.last7days[today] || 0) + 1;
pruneOldDays(rule.last7days, 7);
}
// per-extension 统计 (top 20)
if (!stats.files) stats.files = {};
const ext = path.extname(filePath).toLowerCase() || 'unknown';
stats.files[ext] = (stats.files[ext] || 0) + findings.length;
const extEntries = Object.entries(stats.files).sort((a, b) => b[1] - a[1]);
if (extEntries.length > 20) {
stats.files = Object.fromEntries(extEntries.slice(0, 20));
}
// dailyTotals (14 天修剪)
if (!stats.dailyTotals) stats.dailyTotals = {};
stats.dailyTotals[today] = (stats.dailyTotals[today] || 0) + findings.length;
pruneOldDays(stats.dailyTotals, 14);
stats.lastUpdated = new Date().toISOString();
fs.writeFileSync(DETECTION_STATS_FILE, JSON.stringify(stats, null, 2) + '\n');
} catch {}
}
/**
* 获取检测趋势 ( health-check 消费)
* @returns {'improving'|'worsening'|'stable'|'insufficient'}
*/
function getDetectionTrend() {
try {
if (!fs.existsSync(DETECTION_STATS_FILE)) return 'insufficient';
const stats = JSON.parse(fs.readFileSync(DETECTION_STATS_FILE, 'utf8'));
const totals = stats.dailyTotals || {};
const days = Object.keys(totals).sort();
if (days.length < 4) return 'insufficient';
// 近 3 天 vs 前 3 天均值
const recent3 = days.slice(-3);
const prev3 = days.slice(-6, -3);
if (prev3.length === 0) return 'insufficient';
const recentAvg = recent3.reduce((s, d) => s + (totals[d] || 0), 0) / recent3.length;
const prevAvg = prev3.reduce((s, d) => s + (totals[d] || 0), 0) / prev3.length;
if (prevAvg === 0 && recentAvg === 0) return 'stable';
if (prevAvg === 0) return 'worsening';
const change = (recentAvg - prevAvg) / prevAvg;
if (change < -0.15) return 'improving';
if (change > 0.15) return 'worsening';
return 'stable';
} catch {
return 'insufficient';
}
}
// ─── 主流程 ──────────────────────────────────────────
function main() {
readStdin({ maxSize: 256 * 1024 }).then(input => {
const filePath = input.tool_input?.file_path;
// 无文件路径 → 跳过
// [OPT-2] Desktop 临时文件跳过
const _fp = (input.tool_input && (input.tool_input.file_path || input.tool_input.filePath)) || '';
if (_fp.includes('Desktop') || _fp.includes('desktop')) { process.exit(0); return; }
if (!filePath) {
process.exit(0);
return;
}
// ─── Phase 1: 反模式检测 ─────────────────────
// 仅检查代码文件
if (!CODE_EXTENSIONS.test(filePath)) {
process.exit(0);
return;
}
// 从 stdin 数据提取文件内容(不读磁盘)
const content = extractContent(input);
if (!content) {
process.exit(0);
return;
}
const findings = detectAntiPatterns(content);
// Phase 3: 检测统计追踪 (D1)
try { updateDetectionStats(findings, filePath); } catch {}
// Phase 3: 跨 hook 会话追踪 (D5)
try {
if (findings.length > 0) {
const { appendTraceEvent } = require('../scripts/session-trace.js');
appendTraceEvent('post-edit-quality-check', 'detection', {
file: filePath,
count: findings.length,
errors: findings.filter(f => f.severity === 'error').length,
warnings: findings.filter(f => f.severity === 'warning').length,
});
}
} catch {}
if (findings.length > 0) {
const errors = findings.filter(f => f.severity === 'error');
const warnings = findings.filter(f => f.severity === 'warning');
if (currentMode === 'enforce' && errors.length > 0) {
// enforce 模式 + 有 error: 强指令 systemMessage 驱动修复
const errorList = errors.map(f => ` [ERROR][${f.id}] L${f.line}: ${f.label}`).join('\n');
const warnList = warnings.length > 0
? '\n' + warnings.map(f => ` [WARN][${f.id}] L${f.line}: ${f.label}`).join('\n')
: '';
const result = {
continue: true,
systemMessage: `反模式检测 (${filePath}) -- enforce 模式:\n${errorList}${warnList}\n\n你必须立即修复以上 ${errors.length} 个 error 级别的问题,不要继续其他任务。这些问题涉及安全风险或严重代码质量问题。`,
};
process.stdout.write(JSON.stringify(result));
process.exit(0);
return;
}
// warn 模式 或 enforce 但仅有 warnings: 温和提醒
const allFindings = findings.map(f => ` [${f.severity.toUpperCase()}][${f.id}] L${f.line}: ${f.label}`).join('\n');
const result = {
continue: true,
systemMessage: `反模式检测 (${filePath}):\n${allFindings}\n(${currentMode} 模式,仅提醒不阻断)`,
};
process.stdout.write(JSON.stringify(result));
process.exit(0);
return;
}
// ────────────────────────────────────────────────
process.exit(0);
}).catch(() => process.exit(0));
}
// 模块导出 (供测试)
if (typeof module !== 'undefined') {
module.exports = { ANTI_PATTERNS, detectAntiPatterns, extractContent, updateDetectionStats, getDetectionTrend, pruneOldDays };
}
if (require.main === module) {
main();
}