/**
* @file diagnostics.js
* @description The findings Atlas can safely derive from the model.
*
* Both diagnostics here are **absence** claims — "nothing reads this", "nothing
* invokes this" — and an absence claim is only as good as the completeness of
* the search behind it. Atlas knows exactly where its search was incomplete,
* because it recorded every relationship it could not resolve, so each rule
* checks that record before concluding anything.
*
* The rule this file exists to enforce: **a diagnostic must never conclude
* "never read" from a body the analyser could not follow.** A false AVX_W40 on
* a state key that is read through a computed member would teach a developer
* to stop trusting the whole feature, which costs far more than the warnings
* it suppresses.
* @module lib/compiler/atlas/diagnostics
*/
import { AtlasEdgeKind, AtlasNodeKind, UnresolvedReason } from './AppModel.js';
import { AvenxErrorCodes } from '../../core/runtime/AvenxError.js';
import { BuildError } from '../errors/index.js';
import { reportWarning } from '../utils/warningReporter.js';
/**
* Actions the runtime calls by name, so nothing in a template needs to.
*
* `AvenxComponent` looks each of these up in the component's own methods when
* the corresponding lifecycle moment arrives (see `#runHooks`), which means an
* `<action name="onMount">` is reachable even though no call site exists.
* @type {Set<string>}
*/
export const RUNTIME_INVOKED_ACTIONS = new Set([
'onBeforeMount',
'onMount',
'onBeforeUpdate',
'onUpdate',
'onUnmount',
'onActivate',
'onDeactivate',
'onErrorCaptured',
'setup',
]);
/**
* Unresolved reasons that could be concealing the very relationship a
* diagnostic is about to claim does not exist.
*
* A dynamic member could be the read. A shadowed identifier means a body was
* not followed. A spread could carry the value out. An unknown identifier
* could be the missing link. None of them can be ruled out, so none of them
* may be ignored.
* @type {Set<string>}
*/
const BLOCKING_REASONS = new Set([
UnresolvedReason.DYNAMIC_MEMBER,
UnresolvedReason.SHADOWED_IDENTIFIER,
UnresolvedReason.SPREAD,
UnresolvedReason.UNKNOWN_IDENTIFIER,
UnresolvedReason.SLOT_SCOPE,
]);
/**
* Whether a node belongs to a bridge that nothing imports.
*
* Such a bridge is already reported, once, as omitted from the bundle. Adding
* a warning for every state key and action inside it piles noise on a fact
* that has been stated — and it fires on a freshly scaffolded bridge, before
* the developer has had a chance to import it, which is the worst possible
* first impression for a diagnostic. Once something imports the bridge, its
* members are worth checking individually again.
* @param {object} model - The model.
* @param {object} node - The node being reasoned about.
* @returns {boolean} True when the owning bridge has no consumers.
*/
function inUnimportedBridge(model, node) {
const owner = node.owner ? model.getNode(node.owner) : null;
if (!owner || owner.kind !== AtlasNodeKind.BRIDGE) return false;
return !model.incoming(owner.id).some((edge) => edge.kind === AtlasEdgeKind.IMPORTS);
}
/**
* Resolves the unit an expression site belongs to.
* @param {object} model - The model.
* @param {string} siteId - A node id recorded as an unresolved entry's owner.
* @returns {string|null} The owning component, page or bridge id.
*/
function unitOf(model, siteId) {
const node = model.getNode(siteId);
if (!node) return siteId;
if (node.kind === AtlasNodeKind.COMPONENT || node.kind === AtlasNodeKind.PAGE || node.kind === AtlasNodeKind.BRIDGE) {
return node.id;
}
return node.owner || null;
}
/**
* The units whose incomplete analysis could hide a relationship to `target`.
*
* For a component's own state that is the component itself. For a bridge's
* state or actions it is the bridge plus every unit that imports it, directly
* or through another bridge — anywhere a consumer could be reaching the member
* in a way the analyser could not follow.
* @param {object} model - The model.
* @param {object} target - The node being reasoned about.
* @returns {Set<string>} Unit ids whose unresolved entries block a conclusion.
*/
function blockingUnits(model, target) {
const owner = target.owner;
const units = new Set([owner]);
const ownerNode = model.getNode(owner);
if (!ownerNode || ownerNode.kind !== AtlasNodeKind.BRIDGE) {
return units;
}
const queue = [owner];
while (queue.length > 0) {
const current = queue.shift();
for (const edge of model.incoming(current)) {
if (edge.kind !== AtlasEdgeKind.IMPORTS) continue;
if (units.has(edge.from)) continue;
units.add(edge.from);
queue.push(edge.from);
}
}
return units;
}
/**
* Whether analysis was complete enough to make an absence claim about a node.
* @param {object} model - The model.
* @param {object} target - The node being reasoned about.
* @returns {boolean} True when nothing unresolved could be hiding the relationship.
*/
export function analysisIsComplete(model, target) {
const units = blockingUnits(model, target);
for (const entry of model.unresolved) {
if (!BLOCKING_REASONS.has(entry.reason)) continue;
if (!entry.owner) return false;
const unit = unitOf(model, entry.owner);
if (unit && units.has(unit)) return false;
}
return true;
}
/**
* Finds state that nothing in the application reads.
* @param {object} model - The model.
* @returns {Array<object>} Findings, each with the node and its writers.
*/
export function findUnreadState(model) {
const findings = [];
for (const node of model.nodesOfKind(AtlasNodeKind.STATE)) {
if (inUnimportedBridge(model, node)) continue;
const incoming = model.incoming(node.id);
const reads = incoming.filter((edge) => edge.kind === AtlasEdgeKind.READS);
if (reads.length > 0) continue;
if (!analysisIsComplete(model, node)) continue;
const writers = incoming
.filter((edge) => edge.kind === AtlasEdgeKind.WRITES)
.map((edge) => model.getNode(edge.from))
.filter(Boolean);
findings.push({ node, writers });
}
return findings;
}
/**
* Finds actions no supported invocation surface can reach.
*
* The surfaces are the ones the compiler can see: a template handler or
* binding, another action, a computed, a resource, a guard, a bridge member,
* and the lifecycle names the runtime calls by itself.
* @param {object} model - The model.
* @returns {Array<object>} Findings, each with the unreachable action node.
*/
export function findUnreachableActions(model) {
const findings = [];
for (const node of model.nodesOfKind(AtlasNodeKind.ACTION)) {
if (RUNTIME_INVOKED_ACTIONS.has(node.name)) continue;
if (inUnimportedBridge(model, node)) continue;
const invocations = model.incoming(node.id).filter((edge) => edge.kind === AtlasEdgeKind.INVOKES);
if (invocations.length > 0) continue;
// A dynamic invocation anywhere in reach could be this one.
const units = blockingUnits(model, node);
const hidden = model.unresolved.some((entry) => {
if (entry.reason !== UnresolvedReason.DYNAMIC_INVOCATION && entry.reason !== UnresolvedReason.UNKNOWN_IDENTIFIER) {
return false;
}
if (!entry.owner) return true;
const unit = unitOf(model, entry.owner);
return unit ? units.has(unit) : true;
});
if (hidden) continue;
findings.push({ node });
}
return findings;
}
/**
* Formats an owner id for a message: `bridge:cart` reads as `cart`.
* @param {object} model - The model.
* @param {string|null|undefined} ownerId - The owner node id.
* @returns {string} A display name.
*/
function ownerName(model, ownerId) {
const node = ownerId ? model.getNode(ownerId) : null;
return node ? node.name : String(ownerId || '');
}
/**
* Reports Atlas findings through the compiler's warning machinery.
*
* Routed through `reportWarning` so both codes honour the `warnings` setting
* in `avenx.config.json` — including being escalated to build failures — and
* appear in `avenx check --json` like every other diagnostic.
* @param {object} model - The model.
* @param {object} [config] - The project configuration.
* @returns {{unreadState: number, unreachableActions: number}} What was reported.
*/
export function reportAtlasDiagnostics(model, config = {}) {
const unread = findUnreadState(model);
for (const finding of unread) {
const owner = ownerName(model, finding.node.owner);
const qualified = `${owner}.${finding.node.name}`;
const writers = finding.writers
.map((writer) => `${ownerName(model, writer.owner)}.${writer.name}`)
.sort();
const where = finding.node.loc && finding.node.loc.file
? `${finding.node.loc.file}${finding.node.loc.line ? `:${finding.node.loc.line}` : ''}`
: 'unknown location';
reportWarning(
AvenxErrorCodes.ATLAS_UNREAD_STATE,
new BuildError(
AvenxErrorCodes.ATLAS_UNREAD_STATE,
qualified,
writers.length > 0 ? `written by ${writers.join(', ')} but` : 'declared but',
where,
// Repeated rather than reusing {0}: message formatting substitutes
// each placeholder once, so a second {0} would survive into the text.
qualified,
),
config,
);
}
const unreachable = findUnreachableActions(model);
for (const finding of unreachable) {
const owner = ownerName(model, finding.node.owner);
const where = finding.node.loc && finding.node.loc.file
? `${finding.node.loc.file}${finding.node.loc.line ? `:${finding.node.loc.line}` : ''}`
: 'unknown location';
reportWarning(
AvenxErrorCodes.ATLAS_UNREACHABLE_ACTION,
new BuildError(AvenxErrorCodes.ATLAS_UNREACHABLE_ACTION, `${owner}.${finding.node.name}`, where),
config,
);
}
return { unreadState: unread.length, unreachableActions: unreachable.length };
}
export default { reportAtlasDiagnostics, findUnreadState, findUnreachableActions, analysisIsComplete };