import fs from 'fs';
import path from 'path';
import { bold, cyan, gray, green, yellow, red } from '../colors.js';
import { listTraces, loadTrace, pruneTraces, DEFAULT_KEEP } from '../../lib/core/trace/store.js';
import { formatTrace, summarizeTrace } from '../../lib/core/trace/format.js';
import { generateTest, suggestName } from '../../lib/core/trace/exportTest.js';
import { findContractViolations, formatViolation } from '../../lib/core/trace/contracts.js';
import { Determinism, TraceNodeType } from '../../lib/core/trace/schema.js';
import { annotateTrace, sidecarFileName } from '../../lib/compiler/sourceMapTrace.js';
import { bridgeDependencies } from '../../lib/core/tooling/loadComponent.js';
/**
* Loads the build's source-location sidecar, when one has been produced.
*
* Annotation happens on read rather than on record: a trace stays a record of
* what happened, and the mapping from an action name to a file and a line is a
* property of the build it came from, not of the session.
* @param {object} cli - The CLI instance.
* @returns {object|null} The sidecar, or null when the project has not been built.
*/
function loadSidecar(cli) {
const distDir = path.join(cli.baseDir, cli.config.distDir || 'dist');
const sidecarPath = path.join(distDir, sidecarFileName(cli.config.outputName || 'bundle'));
if (!fs.existsSync(sidecarPath)) {
return null;
}
try {
return JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
} catch {
// A stale or half-written sidecar costs a source location, not a command.
return null;
}
}
/**
* Renders an age as a short relative string.
* @param {number} mtime - Epoch milliseconds.
* @returns {string} e.g. `2m`, `8h`, `3d`.
*/
function age(mtime) {
const seconds = Math.max(0, Math.round((Date.now() - mtime) / 1000));
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;
return `${Math.round(seconds / 86400)}d`;
}
/**
* Pads a cell to a column width.
* @param {string} value - The cell text.
* @param {number} width - Target width.
* @returns {string} The padded cell.
*/
function pad(value, width) {
const text = String(value);
return text.length >= width ? text : text + ' '.repeat(width - text.length);
}
/**
* Prints the message shown when a project has no traces yet.
*/
function printNoTraces() {
console.log('No traces recorded yet.\n');
console.log(`Record one with ${cyan('npx avenx serve --trace')}, reproduce the behaviour in the`);
console.log('browser, then come back and run `avenx trace list`.');
}
/**
* `avenx trace list` — shows stored traces, newest first.
* @param {object} cli - The CLI instance.
* @param {string[]} args - Command arguments.
*/
export function traceList(cli, args = []) {
const entries = listTraces(cli.baseDir);
if (args.includes('--json')) {
console.log(
JSON.stringify(
entries.map((entry) => ({
id: entry.id,
broken: entry.broken,
...(entry.trace ? summarizeTrace(entry.trace) : {}),
})),
null,
2,
),
);
return;
}
if (entries.length === 0) {
printNoTraces();
return;
}
console.log('');
console.log(
bold(`${pad('TRACE ID', 16)}${pad('AGE', 8)}${pad('EVENTS', 9)}${pad('COMPONENTS', 13)}STATUS`),
);
for (const entry of entries) {
if (entry.broken || !entry.trace) {
console.log(`${pad(entry.id, 16)}${pad(age(entry.mtime), 8)}${pad('-', 9)}${pad('-', 13)}${red('unreadable')}`);
continue;
}
const summary = summarizeTrace(entry.trace);
const status =
summary.status === Determinism.DETERMINISTIC ? green('deterministic') : yellow('best-effort');
console.log(
`${pad(summary.id, 16)}${pad(age(entry.mtime), 8)}${pad(summary.events, 9)}${pad(summary.components, 13)}${status}`,
);
}
console.log('');
console.log(gray(`${entries.length} trace${entries.length === 1 ? '' : 's'} in .avenx/traces/`));
console.log(gray('View one with `avenx trace view <id>`, or export it with `avenx trace export <id>`.'));
}
/**
* `avenx trace view` — prints one trace as a causal tree.
* @param {object} cli - The CLI instance.
* @param {string} id - The trace id, or `latest`.
* @param {string[]} args - Command arguments.
*/
export function traceView(cli, id, args = []) {
const target = id || 'latest';
let found;
try {
found = loadTrace(cli.baseDir, target);
} catch (error) {
console.error(red(`❌ ${error.message}`));
process.exitCode = 1;
return;
}
if (!found) {
if (listTraces(cli.baseDir).length === 0) {
printNoTraces();
} else {
console.error(red(`❌ No trace called "${target}". Run \`avenx trace list\` to see what is stored.`));
}
process.exitCode = 1;
return;
}
if (args.includes('--json')) {
console.log(JSON.stringify(found.trace, null, 2));
return;
}
const maxRootsArg = args.find((arg) => arg.startsWith('--roots='));
const maxRoots = maxRootsArg ? Number(maxRootsArg.split('=')[1]) : undefined;
annotateTrace(found.trace, loadSidecar(cli));
console.log('');
console.log(formatTrace(found.trace, maxRoots ? { maxRoots } : {}));
console.log('');
}
/**
* Finds the source file for a component name, so an exported test can mount it.
* @param {string} srcDir - The project source directory.
* @param {string} componentName - The PascalCase class name.
* @returns {string|null} An absolute path, or null when not found.
*/
function findComponentSource(srcDir, componentName) {
if (!componentName || !fs.existsSync(srcDir)) {
return null;
}
const wanted = componentName.toLowerCase();
const stack = [srcDir];
while (stack.length > 0) {
const dir = stack.pop();
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (/\.(component|page)\.js$/i.test(entry.name)) {
const stem = entry.name.replace(/\.(component|page)\.js$/i, '').replace(/[-_]/g, '');
if (stem.toLowerCase() === wanted) {
return full;
}
}
}
}
return null;
}
/**
* `avenx trace export` — writes a runnable regression test for a trace.
* @param {object} cli - The CLI instance.
* @param {string} id - The trace id, or `latest`.
* @param {string[]} args - Command arguments.
*/
export function traceExport(cli, id, args = []) {
const target = id || 'latest';
let found;
try {
found = loadTrace(cli.baseDir, target);
} catch (error) {
console.error(red(`❌ ${error.message}`));
process.exitCode = 1;
return;
}
if (!found) {
if (listTraces(cli.baseDir).length === 0) {
printNoTraces();
} else {
console.error(red(`❌ No trace called "${target}". Run \`avenx trace list\` to see what is stored.`));
}
process.exitCode = 1;
return;
}
const trace = annotateTrace(found.trace, loadSidecar(cli));
const deterministic = (trace.determinism && trace.determinism.status) === Determinism.DETERMINISTIC;
const outIndex = args.findIndex((arg) => arg === '--out' || arg === '-o');
let outPath = outIndex !== -1 && args[outIndex + 1] ? args[outIndex + 1] : null;
if (!outPath) {
const inline = args.find((arg) => arg.startsWith('--out='));
outPath = inline ? inline.slice('--out='.length) : null;
}
if (!outPath) {
outPath = path.join('test', `${suggestName(trace)}.test.js`);
}
const absoluteOut = path.resolve(cli.baseDir, outPath);
const outDir = path.dirname(absoluteOut);
if (fs.existsSync(absoluteOut) && !args.includes('--force') && !args.includes('-f')) {
console.error(red(`❌ ${outPath} already exists. Pass --force to overwrite it.`));
process.exitCode = 1;
return;
}
// The trace travels beside the test: a regression test that depends on a file
// in .avenx/ would break the moment someone ran `avenx trace prune`.
const traceFileName = `${path.basename(absoluteOut).replace(/\.test\.[cm]?js$/, '')}.trace.json`;
const traceOutPath = path.join(outDir, traceFileName);
const srcDir = path.join(cli.baseDir, cli.config.srcDir || 'src');
const componentName = (trace.nodes || []).find((node) => node.component)?.component || null;
// The sidecar knows exactly which file a component came from, so it beats
// guessing from the name. Scanning srcDir is the fallback for a project that
// has not been built since the trace was recorded.
const sidecar = loadSidecar(cli);
const fromSidecar =
sidecar && sidecar.components && componentName && sidecar.components[componentName]
? path.join(cli.baseDir, sidecar.components[componentName].file)
: null;
const componentSource =
fromSidecar && fs.existsSync(fromSidecar) ? fromSidecar : findComponentSource(srcDir, componentName);
const componentPath = componentSource
? `./${path.relative(outDir, componentSource).split(path.sep).join('/')}`
: null;
// A component that imports a bridge needs the real bridge instance handed to
// it, so the generated test imports the bridge module directly. The sidecar
// knows where each bridge lives.
const bridges = [];
if (componentSource) {
for (const bridgeName of bridgeDependencies(componentSource)) {
const entry = sidecar && sidecar.bridges && sidecar.bridges[bridgeName];
const bridgeFile = entry ? path.join(cli.baseDir, entry.file) : null;
if (bridgeFile && fs.existsSync(bridgeFile)) {
bridges.push({
name: bridgeName,
path: `./${path.relative(outDir, bridgeFile).split(path.sep).join('/')}`,
});
}
}
}
const source = generateTest(trace, {
tracePath: `./${traceFileName}`,
componentPath,
componentName,
bridges,
title: `${componentName ? `${componentName}: ` : ''}${suggestName(trace)}`,
});
if (args.includes('--dry-run') || args.includes('-d')) {
console.log(gray(`Would write ${outPath} and ${path.relative(cli.baseDir, traceOutPath)}:\n`));
console.log(source);
return;
}
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(traceOutPath, JSON.stringify(trace, null, 2));
fs.writeFileSync(absoluteOut, source);
console.log('');
console.log(green(`✔ ${outPath}`));
console.log(green(`✔ ${path.relative(cli.baseDir, traceOutPath)}`));
console.log('');
const inputs = (trace.nodes || []).filter(
(node) => (node.type === TraceNodeType.EVENT || node.type === TraceNodeType.NAVIGATION) && !node.parent,
).length;
console.log(`${inputs} recorded input${inputs === 1 ? '' : 's'} · ${(trace.nodes || []).length} nodes`);
if (!componentPath) {
console.log(
yellow(
'\n⚠ The trace did not name a component this project has a source file for.\n' +
' Fill in the mount() body in the generated test before running it.',
),
);
}
if (!deterministic) {
console.log(
yellow('\n⚠ This trace was recorded as best-effort, so replay may not reproduce it.'),
);
for (const reason of (trace.determinism && trace.determinism.reasons) || []) {
console.log(yellow(` - ${reason.reason}${reason.detail ? `: ${reason.detail}` : ''}`));
}
console.log(gray(' The generated test opts in with allowBestEffort and will never report verified.'));
console.log(gray(' Run `avenx trace view` for the full explanation.'));
}
if (trace.redacted) {
console.log(
yellow(`\n⚠ Values at ${(trace.redactions || []).join(', ')} were withheld when recording.`),
);
console.log(gray(' No assertion in the generated test can check them.'));
}
const violations = findContractViolations(trace);
for (const violation of violations) {
console.log(yellow(`\n⚠ ${formatViolation(violation)}`));
}
console.log(gray(`\nRun it with your test runner, e.g. \`node --test ${outPath}\`.`));
}
/**
* `avenx trace prune` — removes stored traces.
* @param {object} cli - The CLI instance.
* @param {string[]} args - Command arguments.
*/
export function tracePrune(cli, args = []) {
const all = args.includes('--all');
const keepArg = args.find((arg) => arg.startsWith('--keep='));
const keep = keepArg ? Number(keepArg.split('=')[1]) : DEFAULT_KEEP;
const explicitId = args.find((arg) => !arg.startsWith('-'));
if (keepArg && (!Number.isInteger(keep) || keep < 0)) {
console.error(red(`❌ --keep must be a whole number, got "${keepArg.split('=')[1]}".`));
process.exitCode = 1;
return;
}
const before = listTraces(cli.baseDir);
if (before.length === 0) {
printNoTraces();
return;
}
if (args.includes('--dry-run') || args.includes('-d')) {
const doomed = explicitId
? before.filter((entry) => entry.id === explicitId)
: all
? before
: before.slice(keep);
if (doomed.length === 0) {
console.log(`Nothing to prune. ${before.length} trace${before.length === 1 ? '' : 's'} stored.`);
return;
}
console.log(`Would remove ${doomed.length} trace${doomed.length === 1 ? '' : 's'}:`);
for (const entry of doomed) {
console.log(` ${entry.id} ${gray(`(${age(entry.mtime)} old)`)}`);
}
return;
}
const removed = pruneTraces(cli.baseDir, { all, keep, id: explicitId });
if (removed.length === 0) {
console.log(`Nothing to prune. ${before.length} trace${before.length === 1 ? '' : 's'} stored.`);
return;
}
console.log(green(`✔ Removed ${removed.length} trace${removed.length === 1 ? '' : 's'}.`));
const remaining = listTraces(cli.baseDir).length;
console.log(gray(`${remaining} remaining in .avenx/traces/`));
}
/**
* Dispatches an `avenx trace <sub>` invocation.
* @param {object} cli - The CLI instance.
* @param {string[]} args - Everything after `trace`.
*/
export function runTrace(cli, args = []) {
const [sub, ...rest] = args;
const positional = rest.filter((arg) => !arg.startsWith('-'));
switch (sub) {
case 'list':
case 'ls':
traceList(cli, rest);
break;
case 'view':
case 'show':
traceView(cli, positional[0], rest);
break;
case 'export':
traceExport(cli, positional[0], rest);
break;
case 'prune':
case 'clean':
tracePrune(cli, rest);
break;
case undefined:
traceList(cli, rest);
break;
default:
console.error(red(`❌ Unknown trace command "${sub}".`));
console.log('');
console.log('Usage:');
console.log(` ${cyan('avenx trace list')} Show recorded traces`);
console.log(` ${cyan('avenx trace view <id|latest>')} Print a trace as a causal tree`);
console.log(` ${cyan('avenx trace export <id|latest>')} Write a regression test for a trace`);
console.log(` ${cyan('avenx trace prune [--all|--keep=N]')} Remove stored traces`);
process.exitCode = 1;
break;
}
}