Source: lib/compiler/atlas/query.js

/**
 * @file query.js
 * @description Resolving a symbol a developer typed, and walking the graph
 * from it.
 *
 * `avenx impact` and `avenx why` are the same traversal in opposite
 * directions, because Atlas edges point the way dependencies point. Impact
 * follows edges *into* a node — everything that would be affected if it
 * changed. Why follows edges *out of* it — everything it is derived from.
 * Keeping them one function is what guarantees the two answers stay
 * consistent with each other.
 * @module lib/compiler/atlas/query
 */

import { AtlasEdgeKind, AtlasNodeKind } from './AppModel.js';

/**
 * Edge kinds that carry containment rather than data flow.
 *
 * A traversal follows them once, to name the component a binding lives in, but
 * never uses them to travel further — otherwise every query would flood with
 * every sibling declaration of everything it touched.
 * @type {Set<string>}
 */
const CONTAINMENT = new Set([AtlasEdgeKind.DECLARES]);

/**
 * How consequential each edge kind is, lowest first.
 *
 * Used only to choose which edge represents a node that several edges reach;
 * it does not affect which nodes are reached.
 * @type {Object<string, number>}
 */
const EDGE_PRIORITY = {
  [AtlasEdgeKind.WRITES]: 0,
  [AtlasEdgeKind.INVOKES]: 1,
  [AtlasEdgeKind.READS]: 2,
  [AtlasEdgeKind.SUBSCRIBES]: 3,
  [AtlasEdgeKind.EMITS]: 4,
  [AtlasEdgeKind.ROUTES_TO]: 5,
  [AtlasEdgeKind.GUARDED_BY]: 6,
  [AtlasEdgeKind.RENDERS]: 7,
  [AtlasEdgeKind.IMPORTS]: 8,
  [AtlasEdgeKind.DECLARES]: 9,
};

/**
 * The priority of an edge kind.
 * @param {string} kind - The edge kind.
 * @returns {number} Its priority, lowest first.
 */
function edgePriority(kind) {
  return EDGE_PRIORITY[kind] === undefined ? 99 : EDGE_PRIORITY[kind];
}

/**
 * Human-readable labels for node kinds.
 * @type {Object<string, string>}
 */
export const KIND_LABELS = {
  [AtlasNodeKind.COMPONENT]: 'component',
  [AtlasNodeKind.PAGE]: 'page',
  [AtlasNodeKind.BRIDGE]: 'bridge',
  [AtlasNodeKind.STATE]: 'state',
  [AtlasNodeKind.COMPUTED]: 'computed',
  [AtlasNodeKind.ACTION]: 'action',
  [AtlasNodeKind.RESOURCE]: 'resource',
  [AtlasNodeKind.GETTER]: 'getter',
  [AtlasNodeKind.EVENT]: 'event',
  [AtlasNodeKind.BINDING]: 'binding',
  [AtlasNodeKind.HANDLER]: 'handler',
  [AtlasNodeKind.ROUTE]: 'route',
  [AtlasNodeKind.GUARD]: 'guard',
};

/**
 * A display name for a node.
 * @param {object} model - The model.
 * @param {object} node - The node.
 * @returns {string} Something a developer can search for.
 */
export function displayName(model, node) {
  if (!node) return '<unknown>';
  if (node.kind === AtlasNodeKind.BINDING) {
    const owner = model.getNode(node.owner);
    const label = node.binding === 'for' ? '<@for>' : node.binding === 'text' ? '{{ }}' : node.binding;
    return `${owner ? owner.name : ''} ${label} ${node.expression ? `"${node.expression}"` : ''}`.trim();
  }
  if (node.kind === AtlasNodeKind.HANDLER) {
    const owner = model.getNode(node.owner);
    return `${owner ? owner.name : ''} @${node.event}="${node.expression}"`.trim();
  }
  if (node.owner) {
    const owner = model.getNode(node.owner);
    return `${owner ? owner.name : node.owner}.${node.name}`;
  }
  return node.name;
}

/**
 * A short `file:line` for a node, when it has one.
 * @param {object} node - The node.
 * @returns {string} The location, or an empty string.
 */
export function locationOf(node) {
  const loc = node && (node.loc || (node.file ? { file: node.file } : null));
  if (!loc || !loc.file) return '';
  return loc.line ? `${loc.file}:${loc.line}` : loc.file;
}

/**
 * Finds the nodes a typed symbol could mean.
 *
 * Accepts, in order of precision: a full node id, `Owner.member`, a bare owner
 * name, and a bare member name. Ambiguity is returned rather than guessed at —
 * `avenx impact items` on a project with three `items` should list them, not
 * pick one.
 * @param {object} model - The model.
 * @param {string} query - What the developer typed.
 * @returns {object[]} Matching nodes, most precise first.
 */
export function resolveSymbol(model, query) {
  const raw = String(query || '').trim();
  if (!raw) return [];

  if (model.hasNode(raw)) return [model.getNode(raw)];

  const lower = raw.toLowerCase();
  const nodes = [...model.nodes.values()];

  /** @type {object[]} */
  const qualified = [];
  /** @type {object[]} */
  const owners = [];
  /** @type {object[]} */
  const members = [];

  const dot = raw.lastIndexOf('.');
  const ownerPart = dot > 0 ? raw.slice(0, dot) : null;
  const memberPart = dot > 0 ? raw.slice(dot + 1) : null;

  for (const node of nodes) {
    if (node.kind === AtlasNodeKind.BINDING || node.kind === AtlasNodeKind.HANDLER) continue;

    if (ownerPart && memberPart && node.owner) {
      const owner = model.getNode(node.owner);
      if (
        owner &&
        owner.name.toLowerCase() === ownerPart.toLowerCase() &&
        String(node.name).toLowerCase() === memberPart.toLowerCase()
      ) {
        qualified.push(node);
        continue;
      }
    }

    if (!node.owner && String(node.name).toLowerCase() === lower) {
      owners.push(node);
      continue;
    }

    if (node.owner && String(node.name).toLowerCase() === lower) {
      members.push(node);
    }
  }

  // A route is named by its path, which contains no dot to split on.
  if (qualified.length === 0 && owners.length === 0) {
    for (const node of nodes) {
      if (node.kind !== AtlasNodeKind.ROUTE) continue;
      if (String(node.name).toLowerCase() === lower || String(node.pattern).toLowerCase() === lower) {
        owners.push(node);
      }
    }
  }

  const ordered = [...qualified, ...owners, ...members];
  const seen = new Set();
  return ordered.filter((node) => {
    if (seen.has(node.id)) return false;
    seen.add(node.id);
    return true;
  });
}

/**
 * Walks the graph from a node, producing a tree of paths.
 *
 * Breadth-first with a visited set, so a node appears once, at its shortest
 * distance from the root. A dependency graph has diamonds all over it — two
 * components reading one bridge getter — and printing every path through them
 * would turn a useful answer into a wall.
 * @param {object} model - The model.
 * @param {string} rootId - Where to start.
 * @param {object} [options] - Traversal options.
 * @param {'in'|'out'} [options.direction] - `in` for impact, `out` for why.
 * @param {number} [options.depth] - Maximum hops. Defaults to 12.
 * @returns {{root: object, children: object[], truncated: boolean}} The tree.
 */
export function walk(model, rootId, options = {}) {
  const direction = options.direction === 'out' ? 'out' : 'in';
  const maxDepth = Number.isFinite(options.depth) ? options.depth : 12;

  const root = model.getNode(rootId);
  if (!root) return { root: null, children: [], truncated: false };

  const visited = new Set([rootId]);
  /** @type {Map<string, object>} */
  const byId = new Map();
  const rootEntry = { node: root, edge: null, children: [], depth: 0 };
  byId.set(rootId, rootEntry);

  let truncated = false;
  const queue = [rootEntry];

  while (queue.length > 0) {
    const current = queue.shift();
    if (current.depth >= maxDepth) {
      truncated = true;
      continue;
    }

    const edges = direction === 'in' ? model.incoming(current.node.id) : model.outgoing(current.node.id);

    // A node is visited once, so when several edges reach it the one that gets
    // shown should be the most consequential. `cart.addQty` both reads
    // `cart.items` (to find an entry) and writes it; reporting only the read
    // would hide the single most important fact in an impact query.
    const ordered = [...edges].sort((a, b) => edgePriority(a.kind) - edgePriority(b.kind));

    for (const edge of ordered) {
      const nextId = direction === 'in' ? edge.from : edge.to;

      // Containment is followed only to name the component a binding or
      // handler lives in. Following it from anywhere else would turn every
      // query into a listing of the whole owner: an impact query on
      // `cart.items` would report every component that merely imports `cart`,
      // which is a different and much weaker claim than the one being made.
      const isContainment = CONTAINMENT.has(edge.kind);
      if (isContainment && direction === 'out') continue;
      if (
        isContainment &&
        current.node.kind !== AtlasNodeKind.BINDING &&
        current.node.kind !== AtlasNodeKind.HANDLER
      ) {
        continue;
      }

      if (visited.has(nextId)) continue;
      const next = model.getNode(nextId);
      if (!next) continue;

      visited.add(nextId);
      const entry = { node: next, edge, children: [], depth: current.depth + 1 };
      byId.set(nextId, entry);
      current.children.push(entry);
      queue.push(entry);
    }
  }

  sortTree(rootEntry);
  return { root: rootEntry, children: rootEntry.children, truncated, size: visited.size - 1 };
}

/**
 * Orders a tree so two runs print identically.
 * @param {object} entry - A tree entry.
 * @returns {void}
 */
function sortTree(entry) {
  entry.children.sort((a, b) => {
    if (a.edge.kind !== b.edge.kind) return a.edge.kind < b.edge.kind ? -1 : 1;
    const fileA = (a.edge.loc && a.edge.loc.file) || '';
    const fileB = (b.edge.loc && b.edge.loc.file) || '';
    if (fileA !== fileB) return fileA < fileB ? -1 : 1;
    const lineA = (a.edge.loc && a.edge.loc.line) || 0;
    const lineB = (b.edge.loc && b.edge.loc.line) || 0;
    if (lineA !== lineB) return lineA - lineB;
    return a.node.id < b.node.id ? -1 : 1;
  });
  for (const child of entry.children) sortTree(child);
}

/**
 * Flattens a walk into a serializable list.
 * @param {object} entry - The tree root from {@link walk}.
 * @returns {object[]} One record per reached node.
 */
export function flatten(entry) {
  const out = [];
  const visit = (current, path) => {
    for (const child of current.children) {
      out.push({
        id: child.node.id,
        kind: child.node.kind,
        name: child.node.name,
        via: child.edge.kind,
        confidence: child.edge.confidence,
        depth: child.depth,
        path: [...path, child.node.id],
        ...(child.edge.loc ? { loc: child.edge.loc } : {}),
        ...(child.edge.path ? { member: child.edge.path } : {}),
      });
      visit(child, [...path, child.node.id]);
    }
  };
  visit(entry, [entry.node.id]);
  return out;
}

/**
 * Collects the unresolved entries relevant to a set of reached nodes.
 *
 * A query's answer is incomplete exactly where analysis was, so the two are
 * reported together rather than leaving the reader to guess.
 * @param {object} model - The model.
 * @param {object[]} reached - Records from {@link flatten}.
 * @param {string} rootId - The queried node id.
 * @returns {object[]} The relevant unresolved entries.
 */
export function relevantUnresolved(model, reached, rootId) {
  const owners = new Set([rootId]);
  const rootNode = model.getNode(rootId);
  if (rootNode && rootNode.owner) owners.add(rootNode.owner);
  for (const record of reached) {
    owners.add(record.id);
    const node = model.getNode(record.id);
    if (node && node.owner) owners.add(node.owner);
  }
  return model.unresolved.filter((entry) => entry.owner && owners.has(entry.owner));
}

export default { resolveSymbol, walk, flatten, displayName, locationOf, relevantUnresolved };