/**
* @file format.js
* @description Renders a trace as a readable causal tree.
*
* The output is deliberately a text tree rather than a graphical inspector.
* A causal trace is a tree of short lines — that is its natural shape — and a
* tree in a terminal is diffable, pasteable into an issue, greppable, and
* available the moment a developer wants it.
*
* Reading order follows causality, not time: each node is printed under the
* thing that caused it, so the answer to "why did this DOM node change" is
* always the line above it.
* @module lib/core/trace/format
*/
import { formatCaptured } from './capture.js';
import { formatNodeRef } from './dom.js';
import { findContractViolations, formatViolation } from './contracts.js';
import {
TraceNodeType,
Determinism,
NonDeterminismReason,
groupChildren,
rootNodes,
} from './schema.js';
/**
* Human-readable explanations for each {@link NonDeterminismReason}.
*
* These live here rather than in `schema.js` because they are presentation,
* not structure — and because `schema.js` is in the runtime's import graph,
* where a block of prose no application will ever render would be dead weight
* in every bundle.
* @type {Object<string, string>}
*/
export const REASON_DESCRIPTIONS = {
[NonDeterminismReason.UNATTRIBUTED_WRITE]:
'State changed with no recorded input to explain it. A timer, an outside event listener or async code outside a <resource> mutated state.',
[NonDeterminismReason.POLLING_RESOURCE]:
'A <resource> declares a pollInterval, so how many times it settled depends on wall-clock time.',
[NonDeterminismReason.UNSERIALIZABLE_VALUE]:
'A recorded value could not be represented in JSON (a DOM node, a class instance or a cycle), so replay cannot restore it exactly.',
[NonDeterminismReason.REDACTED_INPUT]:
'A redaction rule removed a value replay would have to feed back in. The trace is safe to share but cannot be replayed faithfully.',
[NonDeterminismReason.TRUNCATED]:
'The recording buffer filled up and dropped its oldest nodes, so the trace does not start at the beginning of the session.',
[NonDeterminismReason.UNSANDBOXED_GLOBAL]:
'Code outside a template expression read a non-deterministic global (Date, Math.random, crypto). Bridge and imported module code is not sandboxed, so this value was not recorded.',
};
/**
* Renders one node as a single line, without its children.
* @param {object} node - The node.
* @returns {string} The line.
*/
export function formatNode(node) {
switch (node.type) {
case TraceNodeType.EVENT: {
const target = formatNodeRef(node.target);
const where = node.component ? ` ${node.component}` : '';
const value = node.value !== undefined ? ` value=${JSON.stringify(node.value)}` : '';
return `${node.eventType} ${target}${where}${value}`;
}
case TraceNodeType.ACTION: {
// The source location comes from the build's sidecar, when one exists.
// A trace records the action's source text; only the compiler knows the
// file and line it came from.
const where = node.loc && node.loc.line ? ` ${node.loc.file}:${node.loc.line}` : '';
return `action ${node.component ? `${node.component}.` : ''}${node.name}()${where}`;
}
case TraceNodeType.BRIDGE_ACTION: {
const args = Array.isArray(node.args) ? node.args.map(formatCaptured).join(', ') : '';
return `bridge ${node.bridge} · ${node.name}(${args})`;
}
case TraceNodeType.BRIDGE_EMIT:
return `emit ${node.bridge}:${node.event} → ${node.listeners} listener${node.listeners === 1 ? '' : 's'}`;
case TraceNodeType.WRITE: {
if (node.op && node.to === undefined && node.from === undefined) {
return `write ${node.path} (${node.op}, size ${node.size})`;
}
const suffix = node.op ? ` (${node.op})` : '';
return `write ${node.path} ${formatCaptured(node.from)} → ${formatCaptured(node.to)}${suffix}`;
}
case TraceNodeType.WATCHER:
return `woke ${node.name}`;
case TraceNodeType.COMPUTED: {
const ownerName = node.kind === 'getter' ? node.bridge : node.component;
const owner = ownerName ? `${ownerName}.` : '';
const expr = node.expression ? ` [${node.expression}]` : '';
return `${node.kind === 'getter' ? 'getter' : 'computed'} ${owner}${node.name} ${formatCaptured(
node.from,
)} → ${formatCaptured(node.to)}${expr}`;
}
case TraceNodeType.DOM: {
const target = formatNodeRef(node.target);
if (node.op === 'text') {
return `patched ${target} text ${formatCaptured(node.from)} → ${formatCaptured(node.to)}`;
}
if (node.op === 'attr') {
return `patched ${target} @${node.name} ${formatCaptured(node.from)} → ${formatCaptured(node.to)}`;
}
if (node.op === 'remove-attr') {
return `patched ${target} removed @${node.name}`;
}
return `patched ${target} ${node.op}`;
}
case TraceNodeType.RESOURCE:
if (node.phase === 'pending') {
return `resource ${node.name} requested`;
}
return node.status === 'rejected'
? `resource ${node.name} failed: ${node.error ? node.error.message : 'unknown'}`
: `resource ${node.name} resolved ${formatCaptured(node.value)}`;
case TraceNodeType.NAVIGATION:
return `navigate ${node.from || '(none)'} → ${node.to} [${node.page}]`;
case TraceNodeType.GLOBAL:
return `read ${node.source}`;
case TraceNodeType.ERROR:
return `error ${node.name}: ${node.message}`;
case TraceNodeType.CONTRACT:
return `contract ${node.contract}: ${node.detail}`;
case TraceNodeType.REWIND: {
const conflicts = node.conflicts > 0 ? `, ${node.conflicts} conflicted` : '';
return `rewind ${node.action} — ${node.restored} restored${conflicts} [${node.policy}]`;
}
default:
return node.type;
}
}
/**
* Renders the subtree under a node using box-drawing connectors.
* @param {object} node - The node to render.
* @param {Map<number|null, object[]>} children - Children keyed by parent id.
* @param {string} prefix - The accumulated indent.
* @param {boolean} isLast - Whether this node is its parent's last child.
* @param {string[]} out - Lines collected so far.
* @param {Set<number>} seen - Guards against a malformed trace with a parent cycle.
*/
function renderSubtree(node, children, prefix, isLast, out, seen) {
if (seen.has(node.id)) {
return;
}
seen.add(node.id);
const connector = prefix === '' ? '▸ ' : `${isLast ? '└─ ' : '├─ '}`;
out.push(`${prefix}${connector}${formatNode(node)}`);
const kids = children.get(node.id) || [];
const childPrefix = prefix === '' ? ' ' : `${prefix}${isLast ? ' ' : '│ '}`;
kids.forEach((child, index) => {
renderSubtree(child, children, childPrefix, index === kids.length - 1, out, seen);
});
}
/**
* Renders a whole trace as a causal tree, with a determinism summary.
* @param {object} trace - The trace.
* @param {object} [options] - Rendering options.
* @param {number} [options.maxRoots] - How many causal roots to render.
* @returns {string} The rendered trace.
*/
export function formatTrace(trace, options = {}) {
const out = [];
const roots = rootNodes(trace);
const children = groupChildren(trace);
const seen = new Set();
const maxRoots = options.maxRoots || roots.length;
out.push(`Trace ${trace.id} · ${trace.nodes.length} nodes · recorded ${trace.createdAt || 'unknown'}`);
if (trace.meta && trace.meta.url) {
out.push(` ${trace.meta.url}`);
}
out.push('');
const shown = roots.slice(0, maxRoots);
shown.forEach((root, index) => {
renderSubtree(root, children, '', true, out, seen);
if (index < shown.length - 1) {
out.push('');
}
});
if (roots.length > shown.length) {
out.push('', `… ${roots.length - shown.length} more root${roots.length - shown.length === 1 ? '' : 's'} not shown`);
}
const violations = findContractViolations(trace);
if (violations.length > 0) {
out.push('', 'Contract violations observed during this trace:');
for (const violation of violations) {
out.push(` ⚠ ${formatViolation(violation)}`);
}
}
out.push('', formatDeterminism(trace));
return out.join('\n');
}
/**
* Renders a trace's determinism verdict and, when relevant, why it was
* downgraded.
* @param {object} trace - The trace.
* @returns {string} The summary.
*/
export function formatDeterminism(trace) {
const determinism = trace.determinism || { status: Determinism.DETERMINISTIC, reasons: [] };
if (determinism.status === Determinism.DETERMINISTIC) {
return 'Determinism: deterministic — this trace can be exported as a regression test.';
}
const lines = ['Determinism: best-effort — this trace cannot be replayed faithfully.'];
for (const entry of determinism.reasons || []) {
lines.push(` • ${entry.reason}${entry.detail ? `: ${entry.detail}` : ''}`);
const description = REASON_DESCRIPTIONS[entry.reason];
if (description) {
lines.push(` ${description}`);
}
}
if (trace.redacted) {
lines.push(` • redacted: ${(trace.redactions || []).join(', ')}`);
}
return lines.join('\n');
}
/**
* Summarises a trace for a listing row.
* @param {object} trace - The trace.
* @returns {{id: string, events: number, components: number, status: string, createdAt: string}}
*/
export function summarizeTrace(trace) {
const nodes = trace.nodes || [];
const components = new Set();
let events = 0;
for (const node of nodes) {
if (node.component) {
components.add(node.component);
}
if (node.type === TraceNodeType.EVENT || node.type === TraceNodeType.NAVIGATION) {
events++;
}
}
return {
id: trace.id,
events,
components: components.size,
status: (trace.determinism && trace.determinism.status) || Determinism.DETERMINISTIC,
createdAt: trace.createdAt,
};
}