import AvenxCompiler from '../../lib/compiler.js';
import { AtlasNodeKind } from '../../lib/compiler/atlas/AppModel.js';
import { buildAtlas } from '../../lib/compiler/atlas/emit.js';
import {
displayName,
flatten,
locationOf,
relevantUnresolved,
resolveSymbol,
walk,
} from '../../lib/compiler/atlas/query.js';
import { bold, cyan, gray, green, yellow } from '../colors.js';
/**
* Builds the application model, keeping compiler chatter out of query output.
*
* A query is a question about the code, not a build, so the progress lines the
* compiler normally prints would be noise around the answer. Warnings and
* errors still reach the terminal.
* @param {object} cli - The AvenxCLI instance.
* @returns {AppModel} The model.
*/
export function buildModel(cli) {
const compiler = new AvenxCompiler({
...cli.config,
// The CLI resolved the project root already; without passing it on, the
// compiler would re-resolve it from process.cwd() and analyse a different
// project than the one the command was aimed at.
...(cli.baseDir ? { rootDir: cli.baseDir } : {}),
logging: { ...cli.config.logging, level: 'warn' },
});
return compiler.analyze();
}
/**
* Whether the caller asked for machine-readable output.
* @param {string[]} args - CLI arguments.
* @returns {boolean} True for JSON.
*/
function wantsJson(args) {
return args.includes('--json') || args.includes('-j');
}
/**
* Reads a numeric flag such as `--depth=4` or `--depth 4`.
* @param {string[]} args - CLI arguments.
* @param {string} name - The flag name, without dashes.
* @param {number} fallback - The value to use when absent.
* @returns {number} The parsed value.
*/
function numericFlag(args, name, fallback) {
const inline = args.find((arg) => arg.startsWith(`--${name}=`));
if (inline) {
const value = Number(inline.split('=')[1]);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
const index = args.indexOf(`--${name}`);
if (index !== -1 && args[index + 1]) {
const value = Number(args[index + 1]);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
return fallback;
}
/**
* The first positional argument, ignoring flags and their values.
* @param {string[]} args - CLI arguments.
* @returns {string|null} The symbol, or null.
*/
function positional(args) {
const skip = new Set();
args.forEach((arg, index) => {
if ((arg === '--depth' || arg === '-d') && args[index + 1]) skip.add(index + 1);
});
const found = args.find((arg, index) => !arg.startsWith('-') && !skip.has(index));
return found || null;
}
/**
* Prints the candidates for an ambiguous or unknown symbol.
*
* Guessing would be worse than asking: `impact items` on a project with three
* of them should say which three, not silently pick one.
* @param {object} model - The model.
* @param {string} query - What was typed.
* @param {object[]} matches - The candidates.
* @returns {void}
*/
function reportAmbiguous(model, query, matches) {
if (matches.length === 0) {
console.error(`❌ No symbol named "${query}" in the Atlas.`);
console.error(`\nTry ${cyan('avenx atlas')} to see what the model contains.`);
return;
}
console.error(`❌ "${query}" is ambiguous. Did you mean:`);
for (const node of matches) {
console.error(` ${node.id}${locationOf(node) ? gray(` ${locationOf(node)}`) : ''}`);
}
}
/**
* Colours an edge kind so reads and writes are distinguishable at a glance.
* @param {string} kind - The edge kind.
* @returns {string} The rendered label.
*/
function edgeLabel(kind) {
if (kind === 'writes') return yellow(kind);
if (kind === 'invokes') return green(kind);
return cyan(kind);
}
/**
* Renders a traversal as an indented tree.
* @param {object} model - The model.
* @param {object} entry - A tree entry from `walk`.
* @param {string} prefix - The accumulated indent.
* @returns {void}
*/
function printTree(model, entry, prefix = '') {
entry.children.forEach((child, index) => {
const isLast = index === entry.children.length - 1;
const branch = isLast ? '└─ ' : '├─ ';
const nextPrefix = prefix + (isLast ? ' ' : '│ ');
const parts = [edgeLabel(child.edge.kind), displayName(model, child.node)];
if (child.edge.path) parts.push(gray(`.${child.edge.path}`));
if (child.edge.confidence !== 'certain') parts.push(yellow(`[${child.edge.confidence}]`));
const where = locationOf(child.node) || (child.edge.loc ? `${child.edge.loc.file}:${child.edge.loc.line}` : '');
console.log(`${prefix}${branch}${parts.join(' ')}${where ? gray(` ${where}`) : ''}`);
printTree(model, child, nextPrefix);
});
}
/**
* Prints the unresolved entries that bear on a query's answer.
*
* Always printed, even when empty, because "0 unresolved" is the part of the
* answer that says how much of it to trust.
* @param {object[]} entries - Relevant unresolved entries.
* @returns {void}
*/
function printUnresolved(entries) {
if (entries.length === 0) {
console.log(`\n${gray('0 unresolved relationships in this answer.')}`);
return;
}
console.log(`\n${yellow(`${entries.length} unresolved relationship${entries.length === 1 ? '' : 's'}`)} — this answer may be incomplete:`);
for (const entry of entries) {
const where = entry.loc ? gray(` ${entry.loc.file}${entry.loc.line ? `:${entry.loc.line}` : ''}`) : '';
console.log(` ? ${entry.reason}${entry.expr ? ` ${entry.expr}` : ''}${where}`);
}
}
/**
* Executes `avenx atlas` — an overview of the application model.
* @param {object} cli - The AvenxCLI instance.
* @param {string[]} [args] - CLI arguments.
* @returns {void}
*/
export function runAtlas(cli, args = []) {
const model = buildModel(cli);
if (wantsJson(args)) {
console.log(JSON.stringify(buildAtlas(model), null, 2));
return;
}
const counts = model.counts();
console.log(bold(cyan('🗺 Avenx Atlas')));
if (model.errors.length > 0) {
// A partial model must announce itself. An absence in it is not evidence
// of an absence in the application.
console.log(yellow(` ⚠ ${model.errors.length} part${model.errors.length === 1 ? '' : 's'} of this project could not be analysed:`));
for (const failure of model.errors) {
console.log(yellow(` [${failure.code}] ${failure.message.split('\n')[0]}`));
}
console.log(gray(' Everything below is what Atlas could still see.'));
}
console.log(gray(` ${model.nodes.size} nodes · ${model.edges.length} relationships · ${model.unresolved.length} unresolved\n`));
const rows = [
['Components', counts[AtlasNodeKind.COMPONENT]],
['Pages', counts[AtlasNodeKind.PAGE]],
['Bridges', counts[AtlasNodeKind.BRIDGE]],
['State keys', counts[AtlasNodeKind.STATE]],
['Computed', counts[AtlasNodeKind.COMPUTED]],
['Getters', counts[AtlasNodeKind.GETTER]],
['Actions', counts[AtlasNodeKind.ACTION]],
['Resources', counts[AtlasNodeKind.RESOURCE]],
['Bindings', counts[AtlasNodeKind.BINDING]],
['Handlers', counts[AtlasNodeKind.HANDLER]],
['Routes', counts[AtlasNodeKind.ROUTE]],
['Guards', counts[AtlasNodeKind.GUARD]],
];
const width = Math.max(...rows.map(([label]) => label.length));
for (const [label, value] of rows) {
console.log(` ${label.padEnd(width)} ${bold(String(value))}`);
}
for (const kind of [AtlasNodeKind.BRIDGE, AtlasNodeKind.PAGE, AtlasNodeKind.COMPONENT]) {
const nodes = model.nodesOfKind(kind);
if (nodes.length === 0) continue;
console.log(`\n${bold(`${kind[0].toUpperCase()}${kind.slice(1)}s`)}`);
for (const node of nodes) {
const members = model
.outgoing(node.id)
.filter((edge) => edge.kind === 'declares')
.map((edge) => model.getNode(edge.to))
.filter((member) => member && member.kind !== AtlasNodeKind.BINDING && member.kind !== AtlasNodeKind.HANDLER);
const summary = members.length > 0 ? gray(` — ${members.length} declaration${members.length === 1 ? '' : 's'}`) : '';
console.log(` ${node.name}${summary}${node.file ? gray(` ${node.file}`) : ''}`);
}
}
const routes = model.nodesOfKind(AtlasNodeKind.ROUTE);
if (routes.length > 0) {
console.log(`\n${bold('Routes')}`);
for (const route of routes) {
const outgoing = model.outgoing(route.id);
const page = outgoing.find((edge) => edge.kind === 'routes-to');
const guards = outgoing.filter((edge) => edge.kind === 'guarded-by').map((edge) => model.getNode(edge.to).name);
const target = page ? model.getNode(page.to).name : yellow('unresolved');
console.log(` ${route.name.padEnd(12)} → ${target}${guards.length ? gray(` guarded by ${guards.join(', ')}`) : ''}`);
}
}
if (model.unresolved.length > 0) {
/** @type {Object<string, number>} */
const byReason = {};
for (const entry of model.unresolved) {
byReason[entry.reason] = (byReason[entry.reason] || 0) + 1;
}
console.log(`\n${bold(yellow('Unresolved'))} ${gray('— relationships Atlas could not follow')}`);
for (const [reason, count] of Object.entries(byReason).sort()) {
console.log(` ${reason.padEnd(24)} ${count}`);
}
console.log(gray('\n Run `avenx atlas --json` for each one, with its location.'));
} else {
console.log(`\n${green('Every relationship in this project resolved.')}`);
}
}
/**
* Executes `avenx impact <symbol>` and `avenx why <symbol>`.
*
* One implementation because they are one traversal in opposite directions:
* impact follows edges into a node, why follows edges out of it.
* @param {object} cli - The AvenxCLI instance.
* @param {string[]} args - CLI arguments.
* @param {'in'|'out'} direction - Which way to walk.
* @returns {void}
*/
export function runQuery(cli, args, direction) {
const verb = direction === 'in' ? 'impact' : 'why';
const symbol = positional(args);
if (!symbol) {
console.error(`❌ Please name a symbol, e.g. ${cyan(`avenx ${verb} cart.items`)}`);
process.exitCode = 1;
return;
}
const model = buildModel(cli);
const matches = resolveSymbol(model, symbol);
if (matches.length !== 1) {
if (wantsJson(args)) {
console.log(JSON.stringify({ query: symbol, error: matches.length === 0 ? 'not-found' : 'ambiguous', candidates: matches.map((node) => node.id) }, null, 2));
} else {
reportAmbiguous(model, symbol, matches);
}
process.exitCode = 1;
return;
}
const target = matches[0];
const result = walk(model, target.id, { direction, depth: numericFlag(args, 'depth', 12) });
const reached = flatten(result.root);
const unresolved = relevantUnresolved(model, reached, target.id);
if (wantsJson(args)) {
console.log(
JSON.stringify(
{
query: symbol,
direction: direction === 'in' ? 'impact' : 'why',
target: { id: target.id, kind: target.kind, name: target.name, ...(target.loc ? { loc: target.loc } : {}) },
reached,
unresolved,
truncated: result.truncated,
},
null,
2,
),
);
return;
}
const heading = direction === 'in' ? 'What depends on' : 'What this depends on';
console.log(`${bold(cyan(`${heading}: ${displayName(model, target)}`))}`);
console.log(gray(` ${target.kind}${locationOf(target) ? ` ${locationOf(target)}` : ''}\n`));
if (result.children.length === 0) {
console.log(gray(direction === 'in' ? ' Nothing in the application depends on this.' : ' This depends on nothing Atlas models.'));
} else {
printTree(model, result.root);
console.log(gray(`\n${reached.length} related node${reached.length === 1 ? '' : 's'}${result.truncated ? ' (depth limit reached — pass --depth=N for more)' : ''}`));
}
printUnresolved(unresolved);
}
export default { runAtlas, runQuery };