#!/usr/bin/env node 'use strict'; const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const { contractHash } = require('./check_trace_runtime_conformance'); function parseArgs(argv) { const args = { caseDir: '', entry: '', contract: '', out: '', timeoutMs: 120000, json: false, markdown: false, help: false, }; for (let i = 2; i < argv.length; i++) { const arg = argv[i]; if (arg === '--case-dir' || arg === '--case' || arg === '-d') args.caseDir = argv[++i] || ''; else if (arg === '--entry') args.entry = argv[++i] || ''; else if (arg === '--contract') args.contract = argv[++i] || ''; else if (arg === '--out') args.out = argv[++i] || ''; else if (arg === '--timeout-ms') args.timeoutMs = Number(argv[++i] || args.timeoutMs); else if (arg === '--json') args.json = true; else if (arg === '--markdown') args.markdown = true; else if (arg === '--help' || arg === '-h') args.help = true; else throw new Error(`未知参数:${arg}`); } if (!args.json && !args.markdown) args.markdown = true; return args; } function usage() { return `用法: node scripts/run_trace_runtime_audit.js --case-dir case --entry case/result/final.js --markdown node scripts/run_trace_runtime_audit.js --case-dir case --entry case/result/final.py --timeout-ms 180000 --json 说明:在强制 no-send 模式下运行项目审计入口。入口必须读取 WEB_JS_ENV_PATCHER_TRACE_AUDIT_OUT 并写出 observations;本脚本负责绑定 contractHash、runtimeSourceHash 和 probeVersion。`; } function exists(file) { try { return fs.existsSync(file); } catch { return false; } } function stat(file) { try { return fs.statSync(file); } catch { return null; } } function walk(root, out = []) { if (!exists(root)) return out; const st = stat(root); if (!st) return out; if (st.isFile()) { out.push(root); return out; } if (!st.isDirectory()) return out; for (const name of fs.readdirSync(root)) walk(path.join(root, name), out); return out; } function sha256(value) { return crypto.createHash('sha256').update(value).digest('hex'); } function runtimeSourceHash(resultDir) { const entries = []; for (const file of walk(resultDir)) { const relative = (path.relative(resultDir, file) || '.').replace(/\\/g, '/'); if (/(^|\/)(node_modules|dist|build|coverage|tmp)(\/|$)/i.test(relative)) continue; if (!/\.(js|mjs|cjs|ts|py|json)$/i.test(file)) continue; entries.push([relative, sha256(fs.readFileSync(file))]); } entries.sort((a, b) => a[0].localeCompare(b[0])); return sha256(JSON.stringify(entries)); } function resolveCommand(entry) { if (/\.py$/i.test(entry)) return { command: process.env.PYTHON || 'python', args: [entry, '--audit-only'] }; return { command: process.execPath, args: [entry, '--audit-only'] }; } function normalizeObservations(raw) { if (Array.isArray(raw)) return raw; for (const key of ['observations', 'items', 'events', 'apis']) { if (Array.isArray(raw && raw[key])) return raw[key]; } return []; } function normalizeTimeline(raw, observations) { const source = raw && (raw.timeline || raw.eventTimeline || raw.sequenceTimeline); let entries = []; if (Array.isArray(source)) { entries = source; } else if (source && Array.isArray(source.sequence)) { entries = source.sequence; } let ordered = []; let invalidEntries = 0; if (entries.length) { ordered = entries.map((item, index) => ({ id: String(item && typeof item === 'object' ? (item.contractId || item.id || item.key || '') : item), sequence: Number(item && typeof item === 'object' ? (typeof item.sequence !== 'undefined' ? item.sequence : (typeof item.seq !== 'undefined' ? item.seq : index)) : index), })).filter(item => { const valid = Boolean(item.id) && Number.isFinite(item.sequence); if (!valid) invalidEntries += 1; return valid; }).sort((a, b) => a.sequence - b.sequence); } else { for (const observation of observations) { const id = String(observation && (observation.id || observation.contractId || '') || ''); if (!id) continue; const sequences = Array.isArray(observation.sequences) ? observation.sequences : (typeof observation.sequence !== 'undefined' ? [observation.sequence] : []); for (const sequence of sequences) { ordered.push({ id, sequence: Number(sequence) || 0 }); } } ordered.sort((a, b) => a.sequence - b.sequence); } const sequence = []; for (const item of ordered) { if (sequence[sequence.length - 1] !== item.id) sequence.push(item.id); } const uniqueSequences = new Set(ordered.map(item => item.sequence)); const duplicateSequences = ordered.length - uniqueSequences.size; return { schemaVersion: 'trace-runtime-timeline/v1', generatedFrom: entries.length ? 'runtime-event-timeline' : 'grouped-observations', valid: invalidEntries === 0 && duplicateSequences === 0, invalidEntries, duplicateSequences, eventCount: ordered.length, collapsedCount: sequence.length, truncated: false, sequence, sequenceSha256: sha256(JSON.stringify(sequence)), }; } function runAudit(args) { const caseDir = path.resolve(args.caseDir || '.'); const resultDir = path.join(caseDir, 'result'); const entry = path.resolve(args.entry || path.join(resultDir, 'final.js')); const contractPath = path.resolve(args.contract || path.join(caseDir, 'notes', 'trace-runtime-contract.json')); const out = path.resolve(args.out || path.join(caseDir, 'tmp', 'node-trace-runtime-audit.json')); if (!exists(entry)) throw new Error(`审计入口不存在:${entry}`); if (!exists(contractPath)) throw new Error(`Trace runtime contract 不存在:${contractPath}`); const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8').replace(/^\uFEFF/, '')); fs.mkdirSync(path.dirname(out), { recursive: true }); try { if (exists(out)) fs.rmSync(out); } catch {} const command = resolveCommand(entry); const env = { ...process.env, WEB_JS_ENV_PATCHER_AUDIT_ONLY: '1', WEB_JS_ENV_PATCHER_NO_NETWORK: '1', WEB_JS_ENV_PATCHER_NETWORK_MODE: 'no-send', WEB_JS_ENV_PATCHER_TRACE_CONTRACT: contractPath, WEB_JS_ENV_PATCHER_TRACE_AUDIT_OUT: out, }; const child = spawnSync(command.command, command.args, { cwd: resultDir, env, encoding: 'utf8', timeout: Number.isFinite(args.timeoutMs) && args.timeoutMs > 0 ? args.timeoutMs : 120000, windowsHide: true, }); const problems = []; if (child.error) problems.push(`审计入口执行失败:${child.error.message}`); if (child.status !== 0) problems.push(`审计入口退出码不是 0:${child.status}; stderr=${String(child.stderr || '').slice(0, 1000)}`); if (!exists(out)) problems.push(`审计入口没有写出 ${out}`); if (problems.length) { return { clean: false, caseDir, entry, contractPath, out, command, problems, stdout: child.stdout || '', stderr: child.stderr || '' }; } let raw; try { raw = JSON.parse(fs.readFileSync(out, 'utf8').replace(/^\uFEFF/, '')); } catch (err) { return { clean: false, caseDir, entry, contractPath, out, command, problems: [`Node audit 无法解析:${err.message}`], stdout: child.stdout || '', stderr: child.stderr || '' }; } const observations = normalizeObservations(raw); if (!observations.length) problems.push('Node audit 没有 observations / items / events'); const hasNetworkAttempts = raw && (Object.prototype.hasOwnProperty.call(raw, 'networkAttempts') || Object.prototype.hasOwnProperty.call(raw, 'realNetworkRequests')); if (!hasNetworkAttempts) problems.push('Node audit 必须显式记录 networkAttempts=0;缺失字段不能默认视为未访问网络'); const networkAttempts = Number(raw.networkAttempts || raw.realNetworkRequests || 0); if (networkAttempts > 0) problems.push(`audit-only 阶段检测到 ${networkAttempts} 次真实网络尝试`); const timeline = normalizeTimeline(raw, observations); if (!timeline.valid) { problems.push(`Node audit timeline 含无效项或重复 sequence:invalid=${timeline.invalidEntries},duplicate=${timeline.duplicateSequences}`); } if (contract.timeline && contract.timeline.required && timeline.generatedFrom !== 'runtime-event-timeline') { problems.push('Trace contract 要求状态时间线,但审计入口没有输出原始 runtime event timeline;禁止从分组 observations 反推全局顺序'); } const audit = { schemaVersion: 'node-trace-runtime-audit/v3', generatedBy: 'run_trace_runtime_audit.js', generatedAt: new Date().toISOString(), baselineId: contract.baselineId || raw.baselineId || '', contractHash: contractHash(contract), traceSourceHash: contract.traceSourceHash || '', runtimeSourceHash: runtimeSourceHash(resultDir), probeVersion: raw.probeVersion || 'runtime-audit/v3', networkMode: 'no-send', networkAttempts, observations, timeline, runtimeMeta: raw.runtimeMeta || {}, }; fs.writeFileSync(out, `${JSON.stringify(audit, null, 2)}\n`, 'utf8'); return { clean: problems.length === 0, caseDir, entry, contractPath, out, command, problems, audit: { observationCount: observations.length, contractHash: audit.contractHash, runtimeSourceHash: audit.runtimeSourceHash, networkAttempts, timelineEvents: timeline.eventCount, timelineCollapsed: timeline.collapsedCount, }, stdout: child.stdout || '', stderr: child.stderr || '', }; } function renderMarkdown(result) { const lines = [ '# Node Trace-runtime audit 执行结果', '', `- 入口:${result.entry}`, `- contract:${result.contractPath}`, `- audit:${result.out}`, `- 是否通过:${result.clean ? '是' : '否'}`, ]; if (result.audit) { lines.push(`- 观测项:${result.audit.observationCount}`); lines.push(`- contractHash:${result.audit.contractHash}`); lines.push(`- runtimeSourceHash:${result.audit.runtimeSourceHash}`); lines.push(`- 真实网络尝试:${result.audit.networkAttempts}`); lines.push(`- 状态时间线事件:${result.audit.timelineEvents}`); lines.push(`- 状态时间线折叠项:${result.audit.timelineCollapsed}`); } if (result.problems.length) { lines.push('', '## 问题'); for (const problem of result.problems) lines.push(`- ${problem}`); } return `${lines.join('\n')}\n`; } if (require.main === module) { try { const args = parseArgs(process.argv); if (args.help) { console.log(usage()); process.exit(0); } const result = runAudit(args); if (args.json) console.log(JSON.stringify(result, null, 2)); if (args.markdown) process.stdout.write(renderMarkdown(result)); process.exit(result.clean ? 0 : 1); } catch (err) { console.error(err.message || String(err)); console.error(usage()); process.exit(1); } } module.exports = { runAudit, runtimeSourceHash };