Source: lib/compiler/atlas/AppModel.js

/**
 * @file AppModel.js
 * @description The Avenx Atlas data model — the compiler's semantic map of an
 * application.
 *
 * The compiler already computes almost everything Atlas needs: declared state,
 * computed expressions, action bodies, resources, contracts, template ASTs,
 * bridge descriptors and the component dependency graph. Until now it threw
 * all of that away the moment it had emitted a bundle. Atlas is the retained
 * form of that knowledge.
 *
 * ## Shape
 *
 * A model is a set of **nodes** (the application's semantic entities) and a set
 * of **edges** (the relationships between them), plus an explicit list of the
 * things analysis could *not* resolve. The third list is not an afterthought:
 * an Atlas that silently dropped what it could not understand would answer
 * "nothing depends on this" with the same confidence whether that was true or
 * whether it simply failed to look. Every query surface reports it.
 *
 * ## Edge direction
 *
 * Edges always point in the **dependency direction**: `A reads B` is an edge
 * from A to B. That makes the two query verbs the two directions of the same
 * walk:
 *
 * - `avenx why X`    — follow edges *out* of X: what X depends on.
 * - `avenx impact X` — follow edges *into* X: what depends on X.
 * @module lib/compiler/atlas/AppModel
 */

/**
 * The Atlas format version.
 *
 * Bump this when the meaning of an existing field changes or a field a reader
 * depends on is removed. Adding a new optional field does not require a bump;
 * readers must tolerate unknown fields.
 * @type {number}
 */
export const ATLAS_VERSION = 1;

/**
 * The semantic entities Atlas models.
 *
 * These mirror Avenx's own declarations rather than JavaScript's constructs: a
 * developer asks about `cart.items`, not about an object property on a module
 * that happens to be a bridge.
 * @readonly
 * @enum {string}
 */
export const AtlasNodeKind = {
  /** A component class compiled from a `.component.js` file. */
  COMPONENT: 'component',
  /** A page compiled from a `.page.js` file. */
  PAGE: 'page',
  /** A bridge module. */
  BRIDGE: 'bridge',
  /** A single declared state key, on a component or a bridge. */
  STATE: 'state',
  /** A `<computed>` declaration. */
  COMPUTED: 'computed',
  /** An `<action>` body, or an action declared on a bridge. */
  ACTION: 'action',
  /** A `<resource>` declaration. */
  RESOURCE: 'resource',
  /** A bridge getter. */
  GETTER: 'getter',
  /** An event a bridge emits. */
  EVENT: 'event',
  /** A `{{ ... }}` interpolation or a directive expression in a template. */
  BINDING: 'binding',
  /** An `@event="..."` handler in a template. */
  HANDLER: 'handler',
  /** A route pattern registered with `initRouter`. */
  ROUTE: 'route',
  /** A route guard class. */
  GUARD: 'guard',
};

/**
 * The relationships Atlas models.
 * @readonly
 * @enum {string}
 */
export const AtlasEdgeKind = {
  /** The source evaluates the target's value. */
  READS: 'reads',
  /** The source mutates the target. */
  WRITES: 'writes',
  /** The source calls the target. */
  INVOKES: 'invokes',
  /** A component or page renders a child component. */
  RENDERS: 'renders',
  /** A module imports another module (component, page or bridge). */
  IMPORTS: 'imports',
  /** A route resolves to a page. */
  ROUTES_TO: 'routes-to',
  /** A route or page is protected by a guard. */
  GUARDED_BY: 'guarded-by',
  /** Containment: an owner declares one of its members. */
  DECLARES: 'declares',
  /** A bridge action emits an event. */
  EMITS: 'emits',
  /** Code subscribes to a bridge event. */
  SUBSCRIBES: 'subscribes',
};

/**
 * How much an edge can be trusted.
 *
 * There is deliberately no `certain` shortcut for "we did not look". Anything
 * analysis could not follow becomes an {@link UnresolvedReason} entry instead
 * of a confident edge or a silent omission.
 * @readonly
 * @enum {string}
 */
export const Confidence = {
  /** The relationship follows directly from a declaration. */
  CERTAIN: 'certain',
  /** The relationship is likely but analysis could not prove it. */
  POSSIBLE: 'possible',
};

/**
 * Stable reasons a relationship could not be resolved.
 * @readonly
 * @enum {string}
 */
export const UnresolvedReason = {
  /** A member was reached through a computed key: `items[key]`. */
  DYNAMIC_MEMBER: 'dynamic-member',
  /** A root identifier matched no declaration in scope. */
  UNKNOWN_IDENTIFIER: 'unknown-identifier',
  /** A bridge was accessed through a member it does not declare. */
  UNKNOWN_BRIDGE_MEMBER: 'unknown-bridge-member',
  /** A local binding shadows a declaration of the same name. */
  SHADOWED_IDENTIFIER: 'shadowed-identifier',
  /** A call whose callee is not a statically known declaration. */
  DYNAMIC_INVOCATION: 'dynamic-invocation',
  /** A spread whose contents cannot be enumerated. */
  SPREAD: 'spread',
  /** A `<Component :is>`-style tag whose identity is an expression. */
  DYNAMIC_COMPONENT: 'dynamic-component',
  /**
   * A scoped-slot variable. Its value comes from whichever parent fills the
   * slot, which is a relationship the static model does not carry.
   */
  SLOT_SCOPE: 'slot-scope',
  /** A route target that is not a literal page name. */
  DYNAMIC_ROUTE: 'dynamic-route',
};

/**
 * Builds a stable node id.
 *
 * Ids are human-readable on purpose: they show up in `--json` output, in
 * golden test fixtures and in diff review, and `state:bridge:cart.items` is
 * legible where a hash would not be.
 * @param {string} kind - One of {@link AtlasNodeKind}.
 * @param {string|null} owner - The owning node's id, or null for a top-level entity.
 * @param {string} name - The entity's declared name.
 * @returns {string} The node id.
 */
export function nodeId(kind, owner, name) {
  return owner ? `${kind}:${owner}.${name}` : `${kind}:${name}`;
}

/**
 * Compares two locations for deterministic ordering.
 * @param {object|null} a - A location.
 * @param {object|null} b - A location.
 * @returns {number} Sort order.
 */
function compareLoc(a, b) {
  const fileA = (a && a.file) || '';
  const fileB = (b && b.file) || '';
  if (fileA !== fileB) return fileA < fileB ? -1 : 1;
  const lineA = (a && a.line) || 0;
  const lineB = (b && b.line) || 0;
  if (lineA !== lineB) return lineA - lineB;
  return ((a && a.column) || 0) - ((b && b.column) || 0);
}

/**
 * The application's semantic model.
 *
 * Built as a by-product of compilation — nothing here re-parses a file the
 * compiler has already read — and serialized beside the bundle rather than
 * into it.
 */
export class AppModel {
  /**
   * Creates an empty model.
   * @param {object} [options] - Model options.
   * @param {boolean} [options.requireNodes] - Drop an edge naming a node that
   *   does not exist. True for a whole-application model, where a dangling
   *   edge would print as a relationship to nothing. False for a per-unit
   *   fragment, which is built in isolation and legitimately names nodes that
   *   only exist once it is merged.
   */
  constructor(options = {}) {
    /**
     * Whether an edge requires both of its endpoints to exist.
     * @type {boolean}
     */
    this.requireNodes = options.requireNodes !== false;

    /**
     * Nodes by id.
     * @type {Map<string, object>}
     */
    this.nodes = new Map();

    /**
     * Every edge, in insertion order. Deduplicated by {@link AppModel#edgeKey}.
     * @type {object[]}
     */
    this.edges = [];

    /** @type {Set<string>} */
    this.__edgeKeys = new Set();

    /**
     * Relationships analysis could not resolve.
     * @type {object[]}
     */
    this.unresolved = [];

    /** @type {Set<string>} */
    this.__unresolvedKeys = new Set();

    /**
     * Phases that failed while the model was being built.
     *
     * A query command analyses a project that may not compile — that is often
     * exactly why it is being run — so a phase is allowed to fail without
     * losing the rest of the model. What failed is recorded here so the answer
     * can say which part of the project is missing from it.
     * @type {Array<{phase: string, code: string, message: string}>}
     */
    this.errors = [];

    /**
     * Adjacency indexes, invalidated whenever an edge is added.
     * @type {Map<string, object[]>|null}
     */
    this.__out = null;
    /** @type {Map<string, object[]>|null} */
    this.__in = null;
  }

  /**
   * Adds a node, merging into an existing one with the same id.
   *
   * Merging matters because a node can be discovered twice from two directions
   * — a bridge is created when it is analysed and referenced again when a
   * component imports it — and the second sighting must not erase the first
   * one's location or metadata.
   * @param {object} node - The node. Must carry `id` and `kind`.
   * @returns {object} The stored node.
   */
  addNode(node) {
    if (!node || !node.id) {
      throw new TypeError('An Atlas node requires an id.');
    }
    const existing = this.nodes.get(node.id);
    if (!existing) {
      this.nodes.set(node.id, { ...node });
      return this.nodes.get(node.id);
    }
    for (const [key, value] of Object.entries(node)) {
      if (value !== undefined && value !== null && existing[key] === undefined) {
        existing[key] = value;
      }
    }
    return existing;
  }

  /**
   * Whether a node id is present.
   * @param {string} id - The node id.
   * @returns {boolean} True when the node exists.
   */
  hasNode(id) {
    return this.nodes.has(id);
  }

  /**
   * Looks a node up by id.
   * @param {string} id - The node id.
   * @returns {object|undefined} The node.
   */
  getNode(id) {
    return this.nodes.get(id);
  }

  /**
   * The deduplication key for an edge.
   *
   * Location is part of the key so two distinct call sites of the same action
   * stay two edges — `avenx impact` prints them as separate lines, which is
   * the point.
   * @param {object} edge - The edge.
   * @returns {string} The key.
   */
  edgeKey(edge) {
    const loc = edge.loc || {};
    return [edge.from, edge.to, edge.kind, loc.file || '', loc.line || '', loc.column || ''].join('|');
  }

  /**
   * Adds an edge between two nodes.
   *
   * An edge naming a node that does not exist is dropped rather than creating
   * a phantom: every query walks this graph, and a dangling target would print
   * as a relationship to nothing.
   * @param {object} edge - `{from, to, kind, confidence, loc?, path?, via?}`.
   * @returns {boolean} True when the edge was stored.
   */
  addEdge(edge) {
    if (!edge || !edge.from || !edge.to || !edge.kind) {
      return false;
    }
    if (this.requireNodes && (!this.nodes.has(edge.from) || !this.nodes.has(edge.to))) {
      return false;
    }
    const key = this.edgeKey(edge);
    if (this.__edgeKeys.has(key)) {
      return false;
    }
    this.__edgeKeys.add(key);
    this.edges.push({
      from: edge.from,
      to: edge.to,
      kind: edge.kind,
      confidence: edge.confidence || Confidence.CERTAIN,
      ...(edge.loc ? { loc: edge.loc } : {}),
      ...(edge.path ? { path: edge.path } : {}),
      ...(edge.via ? { via: edge.via } : {}),
    });
    this.__out = null;
    this.__in = null;
    return true;
  }

  /**
   * Merges a fragment produced against another model into this one.
   *
   * Node and edge ids are absolute, so a fragment built in isolation slots in
   * without rewriting. This model's own edge rules apply during the merge,
   * which is what lets a fragment be captured leniently and still be checked
   * when it lands.
   * @param {{nodes: object[], edges: object[], unresolved: object[]}} fragment - What to merge.
   * @returns {void}
   */
  merge(fragment) {
    if (!fragment) return;
    for (const node of fragment.nodes || []) this.addNode(node);
    for (const edge of fragment.edges || []) this.addEdge(edge);
    for (const entry of fragment.unresolved || []) this.addUnresolved(entry);
  }

  /**
   * Records something analysis could not resolve.
   * @param {object} entry - `{reason, expr, owner?, loc?, name?}`.
   * @returns {boolean} True when the entry was stored.
   */
  addUnresolved(entry) {
    if (!entry || !entry.reason) {
      return false;
    }
    const loc = entry.loc || {};
    const key = [entry.reason, entry.expr || '', entry.owner || '', loc.file || '', loc.line || ''].join('|');
    if (this.__unresolvedKeys.has(key)) {
      return false;
    }
    this.__unresolvedKeys.add(key);
    this.unresolved.push({
      reason: entry.reason,
      ...(entry.expr !== undefined ? { expr: entry.expr } : {}),
      ...(entry.name !== undefined ? { name: entry.name } : {}),
      ...(entry.owner ? { owner: entry.owner } : {}),
      ...(entry.loc ? { loc: entry.loc } : {}),
    });
    return true;
  }

  /**
   * Builds the adjacency indexes on first use.
   * @private
   */
  #index() {
    if (this.__out && this.__in) return;
    this.__out = new Map();
    this.__in = new Map();
    for (const edge of this.edges) {
      if (!this.__out.has(edge.from)) this.__out.set(edge.from, []);
      this.__out.get(edge.from).push(edge);
      if (!this.__in.has(edge.to)) this.__in.set(edge.to, []);
      this.__in.get(edge.to).push(edge);
    }
  }

  /**
   * Edges leaving a node — what it depends on.
   * @param {string} id - The node id.
   * @returns {object[]} The edges.
   */
  outgoing(id) {
    this.#index();
    return this.__out.get(id) || [];
  }

  /**
   * Edges entering a node — what depends on it.
   * @param {string} id - The node id.
   * @returns {object[]} The edges.
   */
  incoming(id) {
    this.#index();
    return this.__in.get(id) || [];
  }

  /**
   * Every node of a given kind.
   * @param {string} kind - One of {@link AtlasNodeKind}.
   * @returns {object[]} The nodes, ordered by id.
   */
  nodesOfKind(kind) {
    return [...this.nodes.values()].filter((node) => node.kind === kind).sort((a, b) => (a.id < b.id ? -1 : 1));
  }

  /**
   * The unresolved entries recorded against one owner.
   * @param {string} owner - An owner node id.
   * @returns {object[]} The entries.
   */
  unresolvedFor(owner) {
    return this.unresolved.filter((entry) => entry.owner === owner);
  }

  /**
   * A count of each node kind, for the `avenx atlas` summary.
   * @returns {Object<string, number>} Counts keyed by kind.
   */
  counts() {
    /** @type {Object<string, number>} */
    const totals = {};
    for (const kind of Object.values(AtlasNodeKind)) {
      totals[kind] = 0;
    }
    for (const node of this.nodes.values()) {
      totals[node.kind] = (totals[node.kind] || 0) + 1;
    }
    return totals;
  }

  /**
   * Serializes the model.
   *
   * Nodes, edges and unresolved entries are sorted rather than emitted in
   * discovery order, so two builds of unchanged sources produce byte-identical
   * output and the artifact can be diffed in review.
   * @returns {object} The serializable model.
   */
  toJSON() {
    const nodes = [...this.nodes.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));

    const edges = [...this.edges].sort((a, b) => {
      if (a.from !== b.from) return a.from < b.from ? -1 : 1;
      if (a.to !== b.to) return a.to < b.to ? -1 : 1;
      if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
      return compareLoc(a.loc, b.loc);
    });

    const unresolved = [...this.unresolved].sort((a, b) => {
      if (a.reason !== b.reason) return a.reason < b.reason ? -1 : 1;
      const byLoc = compareLoc(a.loc, b.loc);
      if (byLoc !== 0) return byLoc;
      return String(a.expr || '') < String(b.expr || '') ? -1 : 1;
    });

    return {
      nodes,
      edges,
      unresolved,
      ...(this.errors.length > 0 ? { errors: [...this.errors] } : {}),
    };
  }
}

export default AppModel;