/* patch-review-sealed-frame:v2 */ #!/usr/bin/env node 'use strict'; /** * review-report-required - 宪法 v1.4 第 2.1 条强制校验模块 (P1-3, 2026-04-25) * * 背景: 10 天审计发现交付自审报告执行率 < 30%, 多被精简为单行 "审查: PASS"。 * 本模块在 post-edit-dispatcher 链路中以轻量方式推送 [review-required] 提示, * 同时记录到 debug/review-compliance.log, 供 Stop hook / 周报汇总。 * * 触发阈值: * - 源代码扩展名 (.ts/.tsx/.js/.jsx/.py/.go/.rs/.java/.kt/.mjs/.cjs) * - 行数 >= 20 或 文件路径命中安全敏感模式 * * 输出等级: * - SIMPLE: 单行 "审查: PASS/BLOCKED" * - STANDARD: 4 维度 "=== AI CODE REVIEW REPORT ===" * - + RED TEAM: 安全敏感模块 额外 5 问 * - + SEMANTIC DIFF: Edit 工具 + 修改 > 10 行 * * 容错: 任何异常 fail-open 返回 null, 不阻断 dispatcher 链路。 * 补丁标记: REVIEW_REPORT_REQUIRED_v1 */ const path = require('path'); const fs = require('fs'); const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs', '.java', '.kt', '.mjs', '.cjs']; // 安全敏感路径: hooks / src/auth / src/crypto / src/proxy / src/payment / constitution const SECURITY_SENSITIVE_PATTERNS = [ /[\\/]hooks[\\/]/i, /[\\/]src[\\/]auth/i, /[\\/]src[\\/]crypto/i, /[\\/]src[\\/]proxy/i, /[\\/]src[\\/]payment/i, /constitution/i, ]; const MIN_LINES_FOR_CHECK = 20; const STANDARD_TIER_THRESHOLD = 100; const SEMANTIC_DIFF_THRESHOLD = 10; function extractContentAndLineCount(input) { const ti = input && input.tool_input; if (!ti) return { content: '', lineCount: 0 }; const content = ti.content || ti.new_string || ''; const lineCount = content ? content.split(String.fromCharCode(10)).length : 0; return { content: content, lineCount: lineCount }; } function isSecuritySensitive(filePath) { return SECURITY_SENSITIVE_PATTERNS.some(function (re) { return re.test(filePath); }); } function logCompliance(record) { try { const root = require('./lib/root.js'); const debugDir = path.join(root, 'debug'); if (!fs.existsSync(debugDir)) fs.mkdirSync(debugDir, { recursive: true }); const logPath = path.join(debugDir, 'review-compliance.log'); fs.appendFileSync(logPath, JSON.stringify(record) + '\n'); } catch (_e) {} } /** * 核心入口: 由 post-edit-dispatcher 调用 * @param {string} filePath 被修改的文件路径 * @param {object} input 原始 PostToolUse input (含 tool_input / tool_name) * @returns {string|null} systemMessage 片段, 无需提醒则返回 null */ function inlineCheck(filePath, input) { try { if (!filePath) return null; const ext = path.extname(filePath).toLowerCase(); if (!SOURCE_EXTENSIONS.includes(ext)) return null; const pair = extractContentAndLineCount(input); const lineCount = pair.lineCount; const sensitive = isSecuritySensitive(filePath); const large = lineCount >= MIN_LINES_FOR_CHECK; if (!sensitive && !large) return null; const tier = lineCount >= STANDARD_TIER_THRESHOLD ? 'STANDARD' : 'SIMPLE'; const toolName = input && input.tool_name; const requireRedTeam = sensitive; const requireSemanticDiff = lineCount > SEMANTIC_DIFF_THRESHOLD && toolName === 'Edit'; const required = ['审查裁决 │ PASS / BLOCKED (宪章 §2.1 · 封印框)']; if (tier === 'STANDARD') { required.push('封印框 (模板 M): 规范/安全/质量/架构 4 维度 + BOOKWORM · CODE REVIEW ╔╗ 框'); } if (requireRedTeam) { required.push('封印框 (模板 L): CODE REVIEW + 红队 5 问分节 (§11.3)'); } if (requireSemanticDiff) { required.push('封印框 (模板 XL): SEMANTIC DIFF 附节 (§12.1)'); } logCompliance({ ts: new Date().toISOString(), filePath: path.basename(filePath), fullPath: filePath, ext: ext, lineCount: lineCount, toolName: toolName || null, isSensitive: sensitive, tier: tier, requiredCount: required.length, }); const header = '[review-required] ' + path.basename(filePath) + ' 修改 ' + lineCount + ' 行' + (sensitive ? ' (安全敏感)' : '') + ' — 回复末尾必须以【封印框】格式附:'; const bullets = required.map(function (r) { return ' - ' + r; }); return [header].concat(bullets).join(String.fromCharCode(10)); } catch (_e) { return null; } } module.exports = { inlineCheck: inlineCheck, isSecuritySensitive: isSecuritySensitive, SOURCE_EXTENSIONS: SOURCE_EXTENSIONS }; // CLI 自测: node hooks/review-report-checker.js [toolName] if (require.main === module) { const argv = process.argv.slice(2); if (argv.length < 2) { console.log('usage: node review-report-checker.js [toolName]'); process.exit(0); } const fakeInput = { tool_name: argv[2] || 'Edit', tool_input: { file_path: argv[0], new_string: 'x\n'.repeat(parseInt(argv[1], 10) || 0) }, }; const out = inlineCheck(argv[0], fakeInput); console.log(out === null ? '(no reminder)' : out); }