Files

399 lines
16 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const STATEFUL_API_PATTERN = /XMLHttpRequest|fetch|sendBeacon|readyState|readystatechange|loadend|abort|timeout|location\.reload|beforeunload|pagehide|visibilitychange|unload|Worker|SharedWorker|MessagePort|MessageChannel|postMessage|terminate|\.close|appendChild|removeChild|insertBefore|replaceChild|replaceChildren|innerHTML|textContent|querySelector|MutationObserver/i;
const MAX_TIMELINE_SEQUENCE = 10000;
function parseArgs(argv) {
const args = {
caseDir: '',
traces: [],
out: '',
baselineId: '',
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 === '--trace' || arg === '--input' || arg === '-i') args.traces.push(argv[++i] || '');
else if (arg === '--out') args.out = argv[++i] || '';
else if (arg === '--baseline-id') args.baselineId = argv[++i] || '';
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/build_trace_runtime_contract.js --case-dir case --markdown
node scripts/build_trace_runtime_contract.js --trace case/ruyi-trace/logs/trace.ndjson --out case/notes/trace-runtime-contract.json --baseline-id fp-001 --json
说明:从原始 Trace 生成逐 API、realm、receiver、访问类型和行为观测组成的 runtime 契约。该契约是后续 Node audit 的机器可验证输入,不是人工填写的覆盖清单。`;
}
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 stable(value) {
if (Array.isArray(value)) return value.map(stable);
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
}
function digestValue(value) {
if (typeof value === 'undefined') return '';
try { return sha256(JSON.stringify(stable(value))); } catch { return sha256(String(value)); }
}
function rel(root, file) {
return (path.relative(root, file) || '.').replace(/\\/g, '/');
}
function discoverTraceFiles(args, caseDir) {
const files = [];
for (const item of args.traces) {
const file = path.resolve(item);
if (exists(file)) files.push(file);
}
if (!args.traces.length) {
const roots = [
path.join(caseDir, 'ruyi-trace', 'logs'),
path.join(caseDir, 'tmp'),
];
for (const root of roots) {
for (const file of walk(root)) {
const name = path.basename(file);
if (/\.(ndjson|jsonl|json)$/i.test(file) && /trace|ruyi|env/i.test(name) && !/audit|contract|summary/i.test(name)) {
files.push(file);
}
}
}
}
return [...new Set(files.map(file => path.resolve(file)))].sort();
}
function parseTraceFile(file) {
const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
if (/\.json$/i.test(file)) {
const raw = JSON.parse(text);
if (Array.isArray(raw)) return raw;
if (Array.isArray(raw.events)) return raw.events;
if (Array.isArray(raw.items)) return raw.items;
if (Array.isArray(raw.logs)) return raw.logs;
return [raw];
}
const events = [];
for (const [index, line] of text.split(/\r?\n/).entries()) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
event.__line = index + 1;
events.push(event);
} catch (err) {
events.push({ type: 'parse-error', api: `line:${index + 1}`, error: { name: 'SyntaxError', message: err.message }, __line: index + 1 });
}
}
return events;
}
function firstValue(object, keys, fallback) {
if (arguments.length < 3) fallback = '';
for (const key of keys) {
if (object
&& Object.prototype.hasOwnProperty.call(object, key)
&& typeof object[key] !== 'undefined'
&& object[key] !== null
&& object[key] !== '') return object[key];
}
return fallback;
}
function normalizeText(value) {
if (typeof value === 'string') return value;
if (value === null || typeof value === 'undefined') return '';
if (typeof value === 'object') {
return firstValue(value, ['class', 'interface', 'name', 'type', 'id', 'path', 'value'], JSON.stringify(stable(value)));
}
return String(value);
}
function normalizeTraceMember(value) {
const text = normalizeText(value);
return text.replace(/^(get|set|call|construct)\s+/i, '').trim();
}
function normalizeStack(event) {
const stack = Array.isArray(event.stack) ? event.stack : [];
const frame = stack.find(item => item && (item.file || item.filename || item.url || item.line || item.lineNumber));
if (!frame) return '';
const file = firstValue(frame, ['file', 'filename', 'url'], '');
const line = firstValue(frame, ['line', 'lineNumber'], '');
const col = firstValue(frame, ['col', 'column', 'columnNumber'], '');
return [file, line, col].filter(value => value !== '').join(':');
}
function priorityOf(api, event) {
const explicit = normalizeText(firstValue(event, ['priority', 'level'], '')).toUpperCase();
if (/^P[0-2]$/.test(explicit)) return explicit;
if (/XMLHttpRequest|fetch|sendBeacon|window|document|location|navigator|cookie|Storage|Function\.prototype\.toString|reload|writer/i.test(api)) return 'P0';
if (/Object\.|Reflect\.|prototype|descriptor|EventTarget|Performance|Worker|Message|Canvas|WebGL|Audio|screen|Permissions|Plugin|MimeType|DOM|CSS/i.test(api)) return 'P1';
return 'P2';
}
function normalizeObservation(event) {
const interfaceName = normalizeText(firstValue(event, ['interface', 'interfaceName'], ''));
const member = normalizeTraceMember(firstValue(event, ['member', 'property', 'method'], ''));
const explicitApi = normalizeText(firstValue(event, ['api', 'path', 'name', 'prop'], ''));
const api = explicitApi || [interfaceName, member].filter(Boolean).join('.');
if (!api) return null;
const accessType = normalizeText(firstValue(event, ['accessType', 'operation', 'op', 'type'], 'unknown'));
const realm = normalizeText(firstValue(event, ['realmId', 'realm', 'contextId', 'frameId', 'workerId'], 'main'));
const receiver = normalizeText(firstValue(event, ['receiver', 'receiverType', 'thisType', 'target', 'object', 'thisObj'], ''));
const phase = normalizeText(firstValue(event, ['phase', 'writer', 'navigationPhase', 'stage'], ''));
const descriptor = firstValue(event, ['descriptor', 'propertyDescriptor'], undefined);
const prototypeChain = firstValue(event, ['prototypeChain', 'prototypes', 'protoChain'], undefined);
const ownKeys = firstValue(event, ['ownKeys', 'reflectOwnKeys'], undefined);
const ownPropertyNames = firstValue(event, ['ownPropertyNames', 'propertyNames'], undefined);
const ownSymbols = firstValue(event, ['ownSymbols', 'ownPropertySymbols'], undefined);
const result = firstValue(event, ['returnValue', 'result', 'value'], undefined);
const args = firstValue(event, ['arguments', 'args', 'parameters'], undefined);
const error = firstValue(event, ['error', 'exception', 'throw'], undefined);
const brand = normalizeText(firstValue(event, ['brand', 'toStringTag', 'objectToString'], ''));
const constructorName = normalizeText(firstValue(event, ['constructorName', 'constructor'], ''));
const owner = normalizeText(firstValue(event, ['owner', 'definedOn', 'prototypeOwner'], ''));
const sideEffects = firstValue(event, ['sideEffects', 'effects', 'mutations'], undefined);
return {
api,
accessType,
realm,
receiver,
phase,
owner,
brand,
constructorName,
descriptor,
prototypeChain,
ownKeys,
ownPropertyNames,
ownSymbols,
argsDigest: digestValue(args),
resultDigest: digestValue(result),
errorDigest: digestValue(error),
sideEffectsDigest: digestValue(sideEffects),
stack: normalizeStack(event),
sequence: Number(firstValue(event, ['sequence', 'seq', 'index', '__line'], 0)) || 0,
};
}
function contractKey(item) {
return [item.api, item.accessType, item.realm, item.receiver, item.phase].join('\u001f');
}
function addDistinct(target, value, limit = 20) {
if (value === '' || typeof value === 'undefined') return;
const digest = digestValue(value);
if (target.some(item => item.digest === digest)) return;
if (target.length < limit) target.push({ digest, value });
}
function orderStatefulTimeline(events) {
const sourceFiles = [...new Set(events.map(event => event.sourceFile))];
if (sourceFiles.length <= 1) {
return [...events].sort((a, b) => a.fileOrder - b.fileOrder);
}
const sequences = events.map(event => event.traceSequence);
const unique = new Set(sequences);
if (sequences.some(sequence => !Number.isFinite(sequence) || sequence <= 0)
|| unique.size !== sequences.length) {
throw new Error('多文件 Trace 无法建立唯一全局时间线;请合并为单一完整 Trace,或确保所有事件包含跨文件唯一递增 sequence。');
}
return [...events].sort((a, b) => a.traceSequence - b.traceSequence);
}
function buildStatefulTimeline(events) {
const ordered = orderStatefulTimeline(events);
const sequence = [];
for (const event of ordered) {
if (sequence[sequence.length - 1] !== event.contractId) sequence.push(event.contractId);
}
return {
schemaVersion: 'trace-runtime-timeline/v1',
required: sequence.length > 0,
eventCount: ordered.length,
collapsedCount: sequence.length,
truncated: sequence.length > MAX_TIMELINE_SEQUENCE,
sourceFiles: [...new Set(ordered.map(event => event.sourceFile))],
sequence: sequence.slice(0, MAX_TIMELINE_SEQUENCE),
sequenceSha256: sha256(JSON.stringify(sequence)),
};
}
function buildContract(args) {
const caseDir = path.resolve(args.caseDir || '.');
const files = discoverTraceFiles(args, caseDir);
if (!files.length) throw new Error('未找到原始 Trace 文件;请使用 --trace 指定,或把日志放入 case/ruyi-trace/logs/。');
if (!args.traces.length && files.length > 1) {
throw new Error(`自动发现 ${files.length} 份原始 Trace;禁止跨历史采集静默合并。请使用 --trace 显式选择当前 baseline 的完整 Trace,分片文件可重复传入 --trace。`);
}
const sourceFiles = [];
const groups = new Map();
const statefulTimelineEvents = [];
let eventCount = 0;
for (const file of files) {
const text = fs.readFileSync(file);
sourceFiles.push({ file: rel(caseDir, file), sha256: sha256(text), bytes: text.length });
const events = parseTraceFile(file);
for (const [fileEventOrder, event] of events.entries()) {
eventCount += 1;
const item = normalizeObservation(event);
if (!item) continue;
const key = contractKey(item);
if (!groups.has(key)) {
groups.set(key, {
id: sha256(key).slice(0, 24),
api: item.api,
accessType: item.accessType,
realm: item.realm,
receiver: item.receiver,
phase: item.phase,
priority: priorityOf(item.api, event),
count: 0,
sequences: [],
stacks: [],
assertions: {
owner: [],
brand: [],
constructorName: [],
descriptor: [],
prototypeChain: [],
ownKeys: [],
ownPropertyNames: [],
ownSymbols: [],
argsDigest: [],
resultDigest: [],
errorDigest: [],
sideEffectsDigest: [],
},
});
}
const group = groups.get(key);
group.count += 1;
if (item.sequence && group.sequences.length < 50) group.sequences.push(item.sequence);
if (item.stack && !group.stacks.includes(item.stack) && group.stacks.length < 10) group.stacks.push(item.stack);
for (const field of Object.keys(group.assertions)) addDistinct(group.assertions[field], item[field]);
if (STATEFUL_API_PATTERN.test(item.api)) {
statefulTimelineEvents.push({
contractId: group.id,
sourceFile: rel(caseDir, file),
fileOrder: fileEventOrder,
traceSequence: item.sequence,
realm: item.realm,
phase: item.phase,
api: item.api,
});
}
}
}
const contracts = [...groups.values()].sort((a, b) =>
a.priority.localeCompare(b.priority) || a.api.localeCompare(b.api) || a.realm.localeCompare(b.realm)
);
const traceSourceHash = sha256(JSON.stringify(sourceFiles.map(item => [item.file, item.sha256])));
const timeline = buildStatefulTimeline(statefulTimelineEvents);
const contract = {
schemaVersion: 'trace-runtime-contract/v3',
generatedBy: 'build_trace_runtime_contract.js',
generatedAt: new Date().toISOString(),
baselineId: args.baselineId || '',
traceSourceHash,
sourceFiles,
eventCount,
contractCount: contracts.length,
contracts,
timeline,
};
contract.contractHash = sha256(JSON.stringify(stable({
schemaVersion: contract.schemaVersion,
baselineId: contract.baselineId,
traceSourceHash: contract.traceSourceHash,
contracts: contract.contracts,
timeline: contract.timeline,
})));
const out = path.resolve(args.out || path.join(caseDir, 'notes', 'trace-runtime-contract.json'));
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, `${JSON.stringify(contract, null, 2)}\n`, 'utf8');
return { caseDir, out, contract };
}
function renderMarkdown(result) {
const p0 = result.contract.contracts.filter(item => item.priority === 'P0').length;
const p1 = result.contract.contracts.filter(item => item.priority === 'P1').length;
return [
'# Trace-runtime 行为契约生成结果',
'',
`- 输出:${result.out}`,
`- baselineId:${result.contract.baselineId || '未指定'}`,
`- Trace 文件数:${result.contract.sourceFiles.length}`,
`- Trace 事件数:${result.contract.eventCount}`,
`- 行为契约数:${result.contract.contractCount}`,
`- P0:${p0}`,
`- P1:${p1}`,
`- 状态事件:${result.contract.timeline.eventCount}`,
`- 折叠时间线:${result.contract.timeline.collapsedCount}`,
`- traceSourceHash:${result.contract.traceSourceHash}`,
`- contractHash:${result.contract.contractHash}`,
'',
'下一步必须运行 Node audit 并使用 check_trace_runtime_conformance.js 深度比较;不得手工把契约状态改成 matched。',
].join('\n') + '\n';
}
if (require.main === module) {
try {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
process.exit(0);
}
const result = buildContract(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}
}
module.exports = { buildContract, normalizeObservation, digestValue, stable };