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

374 lines
12 KiB
JavaScript
Raw Permalink 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
/**
* 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();
}