reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
#!/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 };
|
||||
Reference in New Issue
Block a user