Source: lib/compiler/rewind/diagnostics.js

/**
 * @file diagnostics.js
 * @description What the compiler can tell a developer about a transaction
 * before the application ships.
 *
 * Three findings, and all three are about the *gap* between what an atomic
 * action promises and what a rewind can actually deliver:
 *
 * - **AVX_W42** — the write set could not be resolved completely, so the two
 *   reports below are incomplete for this action.
 * - **AVX_W43** — the action does something a rewind cannot undo, and here is
 *   each one with its line.
 * - **AVX_W44** — two atomic actions write the same state, so one may rewind
 *   over the other's value.
 *
 * The house rule inherited from `atlas/diagnostics.js` applies with full
 * force: **a finding derived from an incomplete analysis is worse than no
 * finding.** AVX_W44 compares two write sets and is therefore skipped whenever
 * either one is unbounded — an overlap warning computed from a partial set is
 * a false positive and a false negative at the same time. AVX_W42 is the one
 * that reports the incompleteness itself, so it is the only one that does not
 * gate on it.
 *
 * None of this affects the rewind. The journal watches the reactive proxies,
 * not this analysis, so an action nothing here understood is still journaled
 * completely. What is at stake is only whether the developer was told the
 * truth about it in advance.
 * @module lib/compiler/rewind/diagnostics
 */

import { AvenxErrorCodes } from '../../core/runtime/AvenxError.js';
import { BuildError } from '../errors/index.js';
import { reportWarning } from '../utils/warningReporter.js';
import { collectAtomicActions, invokes, MAX_INVOKE_DEPTH } from './writeSet.js';

/**
 * Renders a node's declaration site.
 * @param {object} node - An Atlas node.
 * @returns {string} `file:line`, or a placeholder.
 */
function where(node) {
  if (!node || !node.loc || !node.loc.file) return 'unknown location';
  return node.loc.line ? `${node.loc.file}:${node.loc.line}` : node.loc.file;
}

/**
 * Renders an unresolved entry as one report line.
 * @param {object} entry - An unresolved entry.
 * @returns {string} An indented line.
 */
function unresolvedLine(entry) {
  const site = entry.loc && entry.loc.file
    ? `${entry.loc.file}${entry.loc.line ? `:${entry.loc.line}` : ''}`
    : '';
  const subject = entry.expr || entry.name || '';
  return `  ${entry.reason}${subject ? ` "${subject}"` : ''}${site ? `  ${site}` : ''}`;
}

/**
 * Finds atomic actions whose write set is incomplete.
 * @param {object} model - The finished AppModel.
 * @returns {Array<object>} Findings, each with its action and the reasons.
 */
export function findUnboundedTransactions(model) {
  const findings = [];
  for (const action of collectAtomicActions(model)) {
    if (action.bounded) continue;

    const lines = action.reasons.map(unresolvedLine);
    if (action.depthExceeded) {
      lines.push(`  invoke chain deeper than ${MAX_INVOKE_DEPTH} hops  ${where(action.node)}`);
    }
    for (const entry of action.node.deferred || []) {
      lines.push(
        `  a write inside ${entry.text} runs after the transaction has closed  ` +
          `${action.node.loc && action.node.loc.file ? action.node.loc.file : ''}:${entry.line}`,
      );
    }
    if (lines.length === 0) continue;

    findings.push({ action, lines });
  }
  return findings;
}

/**
 * Finds atomic actions with effects a rewind cannot undo.
 * @param {object} model - The finished AppModel.
 * @returns {Array<object>} Findings, each with its action and the effects.
 */
export function findIrreversibleTransactions(model) {
  const findings = [];
  for (const action of collectAtomicActions(model)) {
    const effects = action.node.irreversible || [];
    if (effects.length === 0) continue;

    const file = action.node.loc && action.node.loc.file ? action.node.loc.file : '';
    const lines = effects.map((effect) => `  ${effect.kind} ${effect.text}  ${file}:${effect.line}`);
    findings.push({ action, effects, lines });
  }
  return findings;
}

/**
 * Finds pairs of atomic actions that write the same state.
 *
 * Only bounded write sets are compared. An unbounded one is already reported
 * as AVX_W42, and intersecting it with anything would answer a question the
 * analysis was not able to ask.
 *
 * A caller and its callee are skipped as well. Their sets overlap by
 * construction, and the runtime joins a nested transaction to the enclosing
 * frame, so the pair cannot produce the conflict this code warns about.
 * @param {object} model - The finished AppModel.
 * @returns {Array<object>} Findings, each with the pair and what they share.
 */
export function findOverlappingTransactions(model) {
  const actions = collectAtomicActions(model).filter(
    (action) => action.bounded && action.writes.length > 0,
  );
  const findings = [];

  for (let i = 0; i < actions.length; i++) {
    for (let j = i + 1; j < actions.length; j++) {
      const left = actions[i];
      const right = actions[j];
      if (invokes(model, left.node.id, right.node.id) || invokes(model, right.node.id, left.node.id)) {
        continue;
      }
      const rightWrites = new Set(right.writes);
      const shared = left.writes.filter((target) => rightWrites.has(target));
      if (shared.length === 0) continue;
      findings.push({ left, right, shared });
    }
  }

  return findings;
}

/**
 * Reports every Rewind finding through the compiler's warning machinery.
 *
 * Routed through `reportWarning` so all three codes honour the `warnings`
 * setting in avenx.config.json — including escalation to a build failure —
 * and appear in `avenx check --json` like every other diagnostic.
 * @param {object} model - The finished AppModel.
 * @param {object} [config] - The project configuration.
 * @returns {{unbounded: number, irreversible: number, overlapping: number}} What was reported.
 */
export function reportRewindDiagnostics(model, config = {}) {
  const unbounded = findUnboundedTransactions(model);
  for (const finding of unbounded) {
    reportWarning(
      AvenxErrorCodes.COMPILER_TRANSACTION_UNBOUNDED,
      new BuildError(
        AvenxErrorCodes.COMPILER_TRANSACTION_UNBOUNDED,
        finding.action.label,
        where(finding.action.node),
        finding.lines.join('\n'),
      ),
      config,
    );
  }

  const irreversible = findIrreversibleTransactions(model);
  for (const finding of irreversible) {
    reportWarning(
      AvenxErrorCodes.COMPILER_TRANSACTION_IRREVERSIBLE,
      new BuildError(
        AvenxErrorCodes.COMPILER_TRANSACTION_IRREVERSIBLE,
        finding.action.label,
        finding.effects.length,
        finding.lines.join('\n'),
      ),
      config,
    );
  }

  const overlapping = findOverlappingTransactions(model);
  for (const finding of overlapping) {
    reportWarning(
      AvenxErrorCodes.COMPILER_TRANSACTION_OVERLAP,
      new BuildError(
        AvenxErrorCodes.COMPILER_TRANSACTION_OVERLAP,
        finding.left.label,
        finding.right.label,
        finding.shared.join(', '),
        where(finding.left.node),
      ),
      config,
    );
  }

  return {
    unbounded: unbounded.length,
    irreversible: irreversible.length,
    overlapping: overlapping.length,
  };
}

export default {
  reportRewindDiagnostics,
  findUnboundedTransactions,
  findIrreversibleTransactions,
  findOverlappingTransactions,
};