242 lines
8.3 KiB
JavaScript
242 lines
8.3 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* B6: Loop 状态文件完整性校验工具
|
||
*
|
||
* 校验 loop-state/ 目录下所有状态文件的完整性:
|
||
* - resume-prompt.md: 非空 + sentinel 标记 + HMAC + 注入检测
|
||
* - progress.md: 非空 + sentinel 标记
|
||
* - loop-state.json: HMAC 签名 + schema 校验
|
||
*
|
||
* 用法:
|
||
* node validate-loop-state.js # 校验全部
|
||
* node validate-loop-state.js --file resume # 校验单个
|
||
* node validate-loop-state.js --fix # 尝试自动修复
|
||
*
|
||
* 退出码: 0=全部通过, 1=有问题, 2=有问题但已修复
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
|
||
const CLAUDE_HOME = process.env.CLAUDE_HOME || path.join(os.homedir(), '.claude');
|
||
const LOOP_DIR = path.join(CLAUDE_HOME, 'loop-state');
|
||
|
||
let stateIntegrity;
|
||
try {
|
||
stateIntegrity = require(path.join(CLAUDE_HOME, 'hooks', 'lib', 'state-integrity.js'));
|
||
} catch {
|
||
console.error('ERROR: 无法加载 state-integrity.js');
|
||
process.exit(1);
|
||
}
|
||
|
||
const args = process.argv.slice(2);
|
||
const fixMode = args.includes('--fix');
|
||
const targetFile = args.includes('--file') ? args[args.indexOf('--file') + 1] : null;
|
||
|
||
const results = [];
|
||
let hasIssue = false;
|
||
let hasFixed = false;
|
||
|
||
function check(name, status, detail) {
|
||
results.push({ name, status, detail });
|
||
if (status === 'FAIL') hasIssue = true;
|
||
if (status === 'FIXED') hasFixed = true;
|
||
const icon = status === 'PASS' ? '\x1b[32m✓\x1b[0m' : status === 'FIXED' ? '\x1b[33m⚡\x1b[0m' : '\x1b[31m✗\x1b[0m';
|
||
console.log(` ${icon} ${name}: ${detail}`);
|
||
}
|
||
|
||
// ─── resume-prompt.md 校验 ───────────────────────────────
|
||
|
||
function validateResumePrompt() {
|
||
const fp = path.join(LOOP_DIR, 'resume-prompt.md');
|
||
console.log('\n📄 resume-prompt.md');
|
||
|
||
// 存在性
|
||
if (!fs.existsSync(fp)) {
|
||
check('resume-exists', 'FAIL', '文件不存在');
|
||
return;
|
||
}
|
||
|
||
// 非空
|
||
const content = fs.readFileSync(fp, 'utf8');
|
||
if (!content.trim()) {
|
||
check('resume-nonempty', 'FAIL', '文件为空');
|
||
return;
|
||
}
|
||
check('resume-nonempty', 'PASS', content.length + ' 字符');
|
||
|
||
// Sentinel 标记
|
||
if (content.includes('<!-- COMPLETE -->')) {
|
||
check('resume-sentinel', 'PASS', '完整性标记存在');
|
||
} else {
|
||
check('resume-sentinel', 'FAIL', '缺少 <!-- COMPLETE --> 标记 (可能是上轮半写中断)');
|
||
if (fixMode) {
|
||
// 尝试使用备份
|
||
const bak = fp + '.bak';
|
||
if (fs.existsSync(bak) && fs.readFileSync(bak, 'utf8').includes('<!-- COMPLETE -->')) {
|
||
fs.copyFileSync(bak, fp);
|
||
check('resume-sentinel-fix', 'FIXED', '已从备份恢复');
|
||
}
|
||
}
|
||
}
|
||
|
||
// HMAC 签名
|
||
const result = stateIntegrity.readTextWithVerification(fp);
|
||
if (result.verified) {
|
||
check('resume-hmac', 'PASS', 'HMAC 签名验证通过');
|
||
} else if (result.error === 'sig-missing') {
|
||
check('resume-hmac', 'FAIL', '签名文件不存在');
|
||
if (fixMode) {
|
||
stateIntegrity.writeTextWithSignature(fp, content);
|
||
check('resume-hmac-fix', 'FIXED', '已重新生成签名');
|
||
}
|
||
} else {
|
||
check('resume-hmac', 'FAIL', '签名不匹配: ' + result.error);
|
||
}
|
||
|
||
// 注入检测
|
||
const sanitized = stateIntegrity.sanitizeResumePrompt(content);
|
||
if (sanitized.injectionDetected) {
|
||
check('resume-injection', 'FAIL', '检测到 ' + sanitized.warnings.length + ' 个疑似注入模式: ' + sanitized.warnings.join(', '));
|
||
if (fixMode) {
|
||
stateIntegrity.writeTextWithSignature(fp, sanitized.content);
|
||
check('resume-injection-fix', 'FIXED', '已清理注入标记并重新签名');
|
||
}
|
||
} else {
|
||
check('resume-injection', 'PASS', '无注入模式');
|
||
}
|
||
}
|
||
|
||
// ─── progress.md 校验 ────────────────────────────────────
|
||
|
||
function validateProgress() {
|
||
const fp = path.join(LOOP_DIR, 'progress.md');
|
||
console.log('\n📊 progress.md');
|
||
|
||
if (!fs.existsSync(fp)) {
|
||
check('progress-exists', 'FAIL', '文件不存在');
|
||
return;
|
||
}
|
||
|
||
const content = fs.readFileSync(fp, 'utf8');
|
||
if (!content.trim()) {
|
||
check('progress-nonempty', 'FAIL', '文件为空');
|
||
return;
|
||
}
|
||
check('progress-nonempty', 'PASS', content.length + ' 字符');
|
||
|
||
// 检查是否有步骤标记
|
||
const stepPattern = /(?:完成|completed|done|step|步骤)\s*\d/i;
|
||
if (stepPattern.test(content)) {
|
||
check('progress-steps', 'PASS', '包含步骤进度标记');
|
||
} else {
|
||
check('progress-steps', 'FAIL', '缺少步骤进度标记');
|
||
}
|
||
}
|
||
|
||
// ─── loop-state.json 校验 ────────────────────────────────
|
||
|
||
function validateLoopState() {
|
||
const fp = path.join(LOOP_DIR, 'loop-state.json');
|
||
console.log('\n⚙️ loop-state.json');
|
||
|
||
if (!fs.existsSync(fp)) {
|
||
check('state-exists', 'FAIL', '文件不存在');
|
||
return;
|
||
}
|
||
|
||
// JSON 格式
|
||
let state;
|
||
try {
|
||
state = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
||
check('state-json', 'PASS', 'JSON 格式正确');
|
||
} catch (e) {
|
||
check('state-json', 'FAIL', 'JSON 解析失败: ' + e.message);
|
||
return;
|
||
}
|
||
|
||
// Schema 校验
|
||
const requiredFields = ['phase', 'status', 'iteration', 'totalCostUsd', 'startTime'];
|
||
const missing = requiredFields.filter(f => !(f in state));
|
||
if (missing.length === 0) {
|
||
check('state-schema', 'PASS', '必填字段完整');
|
||
} else {
|
||
check('state-schema', 'FAIL', '缺少字段: ' + missing.join(', '));
|
||
}
|
||
|
||
// 状态合理性
|
||
const validPhases = ['coding', 'quality', 'deploy', 'verify', 'done'];
|
||
const validStatuses = ['running', 'quality_passed', 'deployed', 'completed', 'aborted',
|
||
'timeout', 'budget_exceeded', 'stuck', 'max_iterations_reached',
|
||
'deploy_failed', 'deploy_blocked_migration'];
|
||
if (state.phase && !validPhases.includes(state.phase)) {
|
||
check('state-phase', 'FAIL', '未知 phase: ' + state.phase);
|
||
}
|
||
if (state.status && !validStatuses.includes(state.status)) {
|
||
check('state-status', 'FAIL', '未知 status: ' + state.status);
|
||
}
|
||
|
||
// 迭代次数合理性
|
||
if (typeof state.iteration === 'number' && state.iteration >= 0) {
|
||
check('state-iteration', 'PASS', '迭代 ' + state.iteration);
|
||
} else {
|
||
check('state-iteration', 'FAIL', '迭代次数无效: ' + state.iteration);
|
||
}
|
||
}
|
||
|
||
// ─── 孤儿文件检测 ────────────────────────────────────────
|
||
|
||
function checkOrphans() {
|
||
console.log('\n🗂️ 孤儿文件');
|
||
|
||
if (!fs.existsSync(LOOP_DIR)) {
|
||
check('orphans', 'PASS', 'loop-state/ 目录不存在');
|
||
return;
|
||
}
|
||
|
||
const files = fs.readdirSync(LOOP_DIR);
|
||
const tmpFiles = files.filter(f => /\.tmp\.\d+$/.test(f));
|
||
if (tmpFiles.length > 0) {
|
||
check('orphan-tmp', 'FAIL', tmpFiles.length + ' 个孤儿 .tmp 文件');
|
||
if (fixMode) {
|
||
for (const f of tmpFiles) {
|
||
try { fs.unlinkSync(path.join(LOOP_DIR, f)); } catch {}
|
||
}
|
||
check('orphan-tmp-fix', 'FIXED', '已清理 ' + tmpFiles.length + ' 个文件');
|
||
}
|
||
} else {
|
||
check('orphan-tmp', 'PASS', '无孤儿文件');
|
||
}
|
||
}
|
||
|
||
// ─── 主流程 ──────────────────────────────────────────────
|
||
|
||
console.log('=== Loop State Integrity Validator ===');
|
||
console.log('目录: ' + LOOP_DIR);
|
||
console.log('模式: ' + (fixMode ? '校验 + 修复' : '仅校验'));
|
||
|
||
if (!targetFile || targetFile === 'resume') validateResumePrompt();
|
||
if (!targetFile || targetFile === 'progress') validateProgress();
|
||
if (!targetFile || targetFile === 'state') validateLoopState();
|
||
if (!targetFile) checkOrphans();
|
||
|
||
// 汇总
|
||
console.log('\n─────────────────────────────');
|
||
const passed = results.filter(r => r.status === 'PASS').length;
|
||
const failed = results.filter(r => r.status === 'FAIL').length;
|
||
const fixed = results.filter(r => r.status === 'FIXED').length;
|
||
const skipped = results.filter(r => r.status === 'SKIP').length;
|
||
console.log(`结果: ${passed} PASS / ${failed} FAIL / ${fixed} FIXED / ${skipped} SKIP`);
|
||
|
||
if (failed > 0 && !hasFixed) {
|
||
console.log('\x1b[31m状态: INTEGRITY_FAIL\x1b[0m');
|
||
process.exit(1);
|
||
} else if (hasFixed) {
|
||
console.log('\x1b[33m状态: INTEGRITY_FIXED\x1b[0m');
|
||
process.exit(2);
|
||
} else {
|
||
console.log('\x1b[32m状态: INTEGRITY_OK\x1b[0m');
|
||
process.exit(0);
|
||
}
|