374 lines
12 KiB
JavaScript
374 lines
12 KiB
JavaScript
|
|
#!/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();
|
|||
|
|
}
|