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