399 lines
16 KiB
JavaScript
399 lines
16 KiB
JavaScript
#!/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 };
|