#!/usr/bin/env node 'use strict'; const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); function parseArgs(argv) { const args = { caseDir: '', requireLive: false, tlsClient: '', beforeRealRequest: false, strictP2: false, 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 === '--require-live') args.requireLive = true; else if (arg === '--tls-client') args.tlsClient = argv[++i] || ''; else if (arg === '--before-real-request') args.beforeRealRequest = true; else if (arg === '--strict-p2') args.strictP2 = true; 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/check_environment_closure.js --case-dir case --before-real-request --markdown node scripts/check_environment_closure.js --case-dir case --before-real-request --require-live --tls-client curl_cffi --json 说明:统一执行 Trace-runtime、WebAPI 行为、对象形状和 XHR/fetch 请求语义闭环。真实请求前必须使用 --before-real-request,任一组件失败都会阻断。`; } 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 hasTraceEvidence(caseDir) { const fixed = [ path.join(caseDir, 'ruyi-trace'), path.join(caseDir, 'tmp', 'env-trace.jsonl'), path.join(caseDir, 'tmp', 'missing-env.json'), path.join(caseDir, 'notes', 'trace-api-inventory.json'), path.join(caseDir, 'notes', 'trace-runtime-contract.json'), ]; if (fixed.some(exists)) return true; return walk(caseDir).some(file => { const relative = (path.relative(caseDir, file) || '.').replace(/\\/g, '/'); if (/(^|\/)(node_modules|dist|build|coverage|vendor)(\/|$)/i.test(relative)) return false; return /(?:^|\/)(?:trace|ruyitrace|missing-env)[^/]*\.(?:json|jsonl|ndjson|log|txt)$/i.test(relative); }); } function evidenceFiles(caseDir) { const roots = ['阶段报告', 'notes', 'tmp', 'fixtures', 'ruyi-trace', 'result']; const files = []; for (const root of roots) { for (const file of walk(path.join(caseDir, root))) { const relative = (path.relative(caseDir, file) || '.').replace(/\\/g, '/'); if (/(^|\/)(node_modules|dist|build|coverage|vendor|third_party|third-party)(\/|$)/i.test(relative)) continue; if (!/\.(?:md|txt|log|json|jsonl|ndjson|js|mjs|cjs|ts|py|html)$/i.test(file)) continue; files.push(file); if (files.length >= 2000) return files; } } return files; } function scanCaseEvidence(caseDir) { const resultDir = path.join(caseDir, 'result'); let hasBrowserEnv = false; let hasNetworkImpl = false; let networkEvidence = false; let webapiEvidence = false; let realmLifecycleEvidence = false; let domCrudEvidence = false; let objectShapeEvidence = false; const hits = { network: [], webapi: [], realmLifecycle: [], domCrud: [], objectShape: [], }; const recordHit = (type, file) => { const relative = (path.relative(caseDir, file) || '.').replace(/\\/g, '/'); if (hits[type].length < 8 && !hits[type].includes(relative)) hits[type].push(relative); }; for (const file of evidenceFiles(caseDir)) { let text = ''; try { text = fs.readFileSync(file, 'utf8').slice(0, 300000); } catch { continue; } const relative = (path.relative(caseDir, file) || '.').replace(/\\/g, '/'); const inResult = relative.startsWith('result/'); if (/\b(XMLHttpRequest|fetch|sendBeacon|Request|Response|Headers)\b|network-transcript|xhr-fetch/i.test(text) || /(?:browser|node)-network-transcript\.(?:json|ndjson)$/i.test(relative)) { networkEvidence = true; recordHit('network', file); if (inResult && /\.(?:js|mjs|cjs|ts|py)$/i.test(file)) hasNetworkImpl = true; } if (/\b(window|document|navigator|EventTarget|Storage|Performance|Element|Node)\b/.test(text) || /(?:browser|node)-env-detection-(?:baseline|audit)\.json$/i.test(relative)) { webapiEvidence = true; recordHit('webapi', file); if (inResult && /\.(?:js|mjs|cjs|ts|py)$/i.test(file)) hasBrowserEnv = true; } if (/iframe|contentWindow|contentDocument|defaultView|\bWorker\b|WorkerGlobalScope|MessageChannel|MessagePort|postMessage|terminate/i.test(text)) { realmLifecycleEvidence = true; webapiEvidence = true; recordHit('realmLifecycle', file); recordHit('webapi', file); } if (/appendChild|removeChild|insertBefore|replaceChild|replaceChildren|cloneNode|importNode|adoptNode|innerHTML|DOMParser|querySelector|MutationObserver|childNodes|getElementsBy/i.test(text)) { domCrudEvidence = true; webapiEvidence = true; recordHit('domCrud', file); recordHit('webapi', file); } if (/Object\.keys|Object\.getOwnPropertyNames|Object\.getOwnPropertyDescriptor|Reflect\.ownKeys|prototypeChain|ownKeys|private-state-leakage/i.test(text)) { objectShapeEvidence = true; recordHit('objectShape', file); } } for (const file of walk(resultDir)) { if (!/\.(js|mjs|cjs|ts|py)$/i.test(file)) continue; const relative = (path.relative(resultDir, file) || '.').replace(/\\/g, '/'); if (/(^|\/)(node_modules|dist|build|coverage)(\/|$)/i.test(relative)) continue; let text = ''; try { text = fs.readFileSync(file, 'utf8'); } catch { continue; } if (/(^|\/)src\/(?:env|node-runtime\/env)\//i.test(relative) || /\b(window|document|navigator|EventTarget|Storage|Performance|Element|Node)\b/.test(text)) { hasBrowserEnv = true; } if (/\b(XMLHttpRequest|fetch|sendBeacon|Request|Response|Headers)\b/.test(text)) hasNetworkImpl = true; } return { resultDir, hasBrowserEnv, hasNetworkImpl, networkEvidence, webapiEvidence, realmLifecycleEvidence, domCrudEvidence, objectShapeEvidence, hits, }; } function collectMetrics(value, out = {}, depth = 0) { if (!value || typeof value !== 'object' || depth > 6) return out; const keys = new Set([ 'baselineId', 'traceSourceHash', 'contractHash', 'runtimeSourceHash', 'blockingMismatches', 'mismatched', 'networkMismatches', 'extraNodeRequests', 'extraNodeEvents', 'networkAttempts', 'sameSessionVerified', 'roundTripCount', ]); if (Array.isArray(value)) { for (const item of value.slice(0, 50)) collectMetrics(item, out, depth + 1); return out; } for (const [key, item] of Object.entries(value)) { if (keys.has(key) && item !== '' && item !== null && typeof item !== 'undefined') { if (!out[key]) out[key] = []; const encoded = typeof item === 'object' ? JSON.stringify(item) : String(item); if (!out[key].includes(encoded) && out[key].length < 10) out[key].push(encoded); } if (item && typeof item === 'object') collectMetrics(item, out, depth + 1); } return out; } function summarizeComponentData(data) { if (!data || typeof data !== 'object') return data; return { schemaVersion: data.schemaVersion || '', clean: data.clean, passed: data.passed, metrics: collectMetrics(data), summary: data.summary && typeof data.summary === 'object' ? data.summary : undefined, problems: Array.isArray(data.problems) ? data.problems.slice(0, 20) : [], warnings: Array.isArray(data.warnings) ? data.warnings.slice(0, 10) : [], }; } function runJson(scriptDir, script, args) { const command = [path.join(scriptDir, script), ...args, '--json']; const ret = spawnSync(process.execPath, command, { encoding: 'utf8', windowsHide: true, timeout: 180000, }); let rawData = null; try { rawData = JSON.parse(String(ret.stdout || '').trim()); } catch {} return { script, exitCode: ret.status, clean: ret.status === 0 && rawData && rawData.clean !== false && rawData.passed !== false, data: summarizeComponentData(rawData), stderr: String(ret.stderr || '').trim().slice(0, 2000), stdoutPreview: rawData ? '' : String(ret.stdout || '').trim().slice(0, 2000), }; } function check(args) { const caseDir = path.resolve(args.caseDir || '.'); const scriptDir = __dirname; const signals = scanCaseEvidence(caseDir); const trace = hasTraceEvidence(caseDir); const components = []; if (trace) { const traceArgs = ['--case-dir', caseDir, '--require-runtime-closure']; if (args.beforeRealRequest) traceArgs.push('--require-stage-audit'); components.push(runJson(scriptDir, 'check_trace_api_coverage.js', traceArgs)); } if (signals.hasBrowserEnv || signals.webapiEvidence || signals.objectShapeEvidence || trace || args.beforeRealRequest) { components.push(runJson(scriptDir, 'check_object_shape_audit.js', ['--case-dir', caseDir, '--require'])); components.push(runJson(scriptDir, 'check_webapi_env_detection_matrix.js', ['--case-dir', caseDir, '--require'])); } if (signals.hasBrowserEnv || signals.hasNetworkImpl || signals.webapiEvidence || args.beforeRealRequest) { components.push(runJson(scriptDir, 'check_code_quality.js', ['--case-dir', caseDir])); } if (signals.hasBrowserEnv || signals.hasNetworkImpl || signals.webapiEvidence) { components.push(runJson(scriptDir, 'check_webapi_addon_coverage.js', ['--case-dir', caseDir, '--warnings-as-errors'])); } if (signals.hasNetworkImpl || signals.networkEvidence || args.requireLive) { const semanticsArgs = ['--case-dir', caseDir, '--require', '--require-no-send', '--out', path.join(caseDir, 'tmp', 'xhr-fetch-semantics-audit.json')]; components.push(runJson(scriptDir, 'check_xhr_fetch_semantics.js', semanticsArgs)); const bridgeArgs = ['--case-dir', caseDir]; if (args.requireLive || args.beforeRealRequest) bridgeArgs.push('--require-live'); if (args.tlsClient) bridgeArgs.push('--tls-client', args.tlsClient); components.push(runJson(scriptDir, 'check_xhr_fetch_session_bridge.js', bridgeArgs)); } const problems = []; for (const component of components) { if (!component.clean) { const detail = component.data && Array.isArray(component.data.problems) ? component.data.problems.slice(0, 20).join(';') : (component.stderr || component.stdoutPreview || `exit=${component.exitCode}`); problems.push(`${component.script} 未通过:${detail}`); } } if (args.beforeRealRequest && !components.length) problems.push('真实请求前没有触发任何环境闭环组件,说明 case 结构或检测信号缺失'); return { schemaVersion: 'environment-closure/v3', generatedBy: 'check_environment_closure.js', caseDir, clean: problems.length === 0, beforeRealRequest: args.beforeRealRequest, requireLive: args.requireLive, tlsClient: args.tlsClient || '', signals: { trace, browserEnv: signals.hasBrowserEnv, networkImpl: signals.hasNetworkImpl, networkEvidence: signals.networkEvidence, webapiEvidence: signals.webapiEvidence, realmLifecycleEvidence: signals.realmLifecycleEvidence, domCrudEvidence: signals.domCrudEvidence, objectShapeEvidence: signals.objectShapeEvidence, evidenceFiles: signals.hits, }, components, problems, }; } function renderMarkdown(result) { const lines = [ '# 补环境闭环总门禁', '', `- case:${result.caseDir}`, `- 真实请求前模式:${result.beforeRealRequest ? '是' : '否'}`, `- 要求 live session:${result.requireLive ? '是' : '否'}`, `- TLS 客户端:${result.tlsClient || '未指定'}`, `- 是否通过:${result.clean ? '是' : '否'}`, '', '## 检测信号', `- Trace:${result.signals.trace ? '是' : '否'}`, `- 浏览器环境实现:${result.signals.browserEnv ? '是' : '否'}`, `- XHR/fetch 实现:${result.signals.networkImpl ? '是' : '否'}`, `- 网络证据:${result.signals.networkEvidence ? '是' : '否'}`, `- Realm / 消息生命周期证据:${result.signals.realmLifecycleEvidence ? '是' : '否'}`, `- DOM CRUD 证据:${result.signals.domCrudEvidence ? '是' : '否'}`, '', '## 组件', ]; if (!result.components.length) lines.push('- 无'); for (const component of result.components) { lines.push(`- ${component.script}:${component.clean ? '通过' : '失败'},exit=${component.exitCode}`); const metrics = component.data && component.data.metrics ? component.data.metrics : {}; const metricText = Object.entries(metrics) .map(([key, values]) => `${key}=${values.join('|')}`) .join(';'); if (metricText) lines.push(` ${metricText}`); } 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 = check(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 = { check };