/**
* @file build.js
* @description Turns what the compiler already knows into Atlas nodes and edges.
*
* Nothing in this module re-reads or re-parses a file the compiler has already
* handled. `addComponentUnit` is handed the exact objects `ComponentParser.parse`
* produced on its way to generating a class; `addBridgeUnit` is handed the
* descriptor `analyzeBridge` produced on its way to emitting a bridge. That is
* the whole design intent: Atlas is retention, not a second analysis pass.
*
* The one thing it does read for itself is the template — and only because the
* template the compiler validates has already been rewritten past the point
* where offsets mean anything. See `source.js`.
* @module lib/compiler/atlas/build
*/
import path from 'path';
import collectTemplateEvents from '../templateEvents.js';
import { extractSubscriptions, memberParams } from '../BridgeParser.js';
import { AtlasEdgeKind, AtlasNodeKind, Confidence, UnresolvedReason, nodeId } from './AppModel.js';
import { COLLECTION_METHODS, ELEMENT_METHODS, formatPath, patternBindings, resolveReference, scanReferences } from './resolve.js';
import { declarationLines, escapeName, lineIndex, lineOf, maskDeclarations, positionAt } from './source.js';
import { findDeferredWrites, findIrreversibleEffects } from '../rewind/effects.js';
/**
* Node kinds that own members.
* @type {Set<string>}
*/
const OWNER_KINDS = new Set([AtlasNodeKind.COMPONENT, AtlasNodeKind.PAGE, AtlasNodeKind.BRIDGE]);
/**
* Records what a rewind of this action could not undo.
*
* Scanned here, where the body is in hand, rather than in the diagnostics
* pass: the findings are a few objects per atomic action, and keeping them on
* the node means the diagnostic never has to re-read a file the compiler has
* already read — and means the cached fragment carries them too.
*
* Only atomic actions are scanned. An ordinary action has nothing to report,
* because nothing is claiming its effects will be undone.
* @param {object} model - The AppModel being built.
* @param {string} nodeIdentifier - The action's node id.
* @param {string} body - The action body source.
* @param {number} baseLine - The line the body starts on in its file.
* @returns {void}
*/
function recordRewindEffects(model, nodeIdentifier, body, baseLine) {
const node = model.getNode(nodeIdentifier);
if (!node || !node.atomic) return;
const irreversible = findIrreversibleEffects(body, { baseLine });
const deferred = findDeferredWrites(body, { baseLine });
if (irreversible.length > 0) {
node.irreversible = irreversible;
}
if (deferred.length > 0) {
node.deferred = deferred;
}
}
/**
* Maps a resolved target descriptor to a node id.
* @param {object} target - `{kind, owner, name}` from resolveReference.
* @returns {string} The node id.
*/
function targetId(target) {
if (target.kind === AtlasNodeKind.BRIDGE) {
return nodeId(AtlasNodeKind.BRIDGE, null, target.name);
}
return nodeId(target.kind, target.owner, target.name);
}
/**
* Converts a project-absolute path to the form Atlas reports.
* @param {string} filePath - An absolute path.
* @param {string} rootDir - The project root.
* @returns {string} A root-relative, forward-slashed path.
*/
export function relativePath(filePath, rootDir) {
return path.relative(rootDir, filePath).split(path.sep).join('/');
}
/**
* Records the edges one scanned expression implies.
*
* Shared by every expression site — computed, action, resource, template
* binding, event handler, bridge member — so that a read means the same thing
* wherever it was written.
* @param {object} model - The AppModel being built.
* @param {object} options - The analysis site.
* @param {string} options.from - The node id the expression belongs to.
* @param {string} options.code - The expression source.
* @param {object} options.scope - The resolution scope.
* @param {object} options.loc - `{file, line, column}` of the site.
* @param {boolean} [options.writeBack] - Treat reads as read-and-write, for
* two-way bindings.
* @returns {void}
*/
export function analyzeExpression(model, options) {
const { from, code, loc, writeBack } = options;
if (!code || !code.trim()) return;
const { references, aliases, localAliases, locals, notes } = scanReferences(code);
// The names this body binds are only known after scanning it, so the scope
// used for resolution is the caller's scope plus those. Without this every
// parameter and `const` would be reported as an unknown identifier.
// `extraLocals` carries bindings declared outside the scanned text, which is
// how a bridge action's parameter list reaches the analysis of its body.
const bound = new Set(locals);
for (const name of options.extraLocals || []) bound.add(name);
const scope = { ...options.scope, locals: bound, aliases: new Map() };
for (const alias of localAliases || []) {
const resolution = resolveReference(
{ kind: 'read', root: alias.root, segments: alias.segments, dynamic: false, text: alias.name },
scope,
);
if (!resolution.target) continue;
// `items.find(...)` yields an element; `items.filter(...)` yields a
// collection. Either way the trailing method is not part of the path.
const path = [...resolution.path];
const last = path[path.length - 1];
if (ELEMENT_METHODS.has(last)) {
path.pop();
path.push('[]');
} else if (COLLECTION_METHODS.has(last)) {
path.pop();
}
scope.aliases.set(alias.name, { target: resolution.target, path });
}
for (const note of notes) {
model.addUnresolved({ reason: note.reason, expr: note.expr, owner: from, loc });
}
/**
* Records one resolved relationship.
* @param {object} ref - The scanned reference.
* @param {string} kind - The edge kind.
* @returns {void}
*/
const record = (ref, kind) => {
const resolution = resolveReference(ref, scope);
if (resolution.unresolved) {
// A built-in method that resolved to nothing is not a finding: `push`
// was already recorded as a write to its receiver.
if (!ref.builtinMethod) {
model.addUnresolved({
reason: resolution.unresolved.reason,
expr: resolution.unresolved.expr,
name: resolution.unresolved.name,
owner: from,
loc,
});
}
return;
}
if (!resolution.target) return;
if (ref.dynamic) {
model.addUnresolved({
reason: UnresolvedReason.DYNAMIC_MEMBER,
expr: ref.text,
owner: from,
loc,
});
}
const to = targetId(resolution.target);
const node = model.getNode(to);
// Only a callable target can be invoked. A chain that ends on state and is
// called is something Atlas cannot follow, and says so.
let edgeKind = kind;
if (kind === AtlasEdgeKind.INVOKES && node && node.kind !== AtlasNodeKind.ACTION) {
if (ref.builtinMethod) return;
edgeKind = AtlasEdgeKind.READS;
}
model.addEdge({
from,
to,
kind: edgeKind,
confidence: resolution.confidence,
loc,
...(resolution.path.length > 0 ? { path: formatPath(resolution.path) } : {}),
});
};
for (const ref of references) {
if (ref.kind === 'invoke') {
record(ref, AtlasEdgeKind.INVOKES);
} else if (ref.kind === 'write') {
record(ref, AtlasEdgeKind.WRITES);
if (ref.alsoReads) record({ ...ref, kind: 'read' }, AtlasEdgeKind.READS);
} else {
record(ref, AtlasEdgeKind.READS);
if (writeBack) record({ ...ref, kind: 'write' }, AtlasEdgeKind.WRITES);
}
}
// `const { items } = cart` reads cart.items as surely as `cart.items` does.
for (const alias of aliases) {
record(
{ kind: 'read', root: alias.root, segments: [...alias.segments, alias.member], dynamic: false, text: `${alias.root}.${alias.member}` },
AtlasEdgeKind.READS,
);
}
}
/**
* Adds a bridge and everything it declares to the model.
* @param {object} model - The AppModel being built.
* @param {object} descriptor - A descriptor from `analyzeBridge`.
* @param {object} context - `{rootDir, source, bridges}`.
* @returns {string} The bridge's node id.
*/
export function addBridgeUnit(model, descriptor, context) {
const { rootDir, source, bridges } = context;
const file = relativePath(descriptor.filePath, rootDir);
const ownerId = nodeId(AtlasNodeKind.BRIDGE, null, descriptor.name);
const starts = source ? lineIndex(source) : [0];
model.addNode({ id: ownerId, kind: AtlasNodeKind.BRIDGE, name: descriptor.name, file });
/**
* Declares one member of the bridge.
* @param {string} kind - The node kind.
* @param {string} name - The member name.
* @param {number|null} line - Its line, when known.
* @returns {string} The member's node id.
*/
const declare = (kind, name, line) => {
const id = nodeId(kind, ownerId, name);
model.addNode({ id, kind, name, owner: ownerId, loc: { file, ...(line ? { line } : {}) } });
model.addEdge({ from: ownerId, to: id, kind: AtlasEdgeKind.DECLARES, confidence: Confidence.CERTAIN });
return id;
};
const memberLine = (name) => {
const member = (descriptor.members || []).find((entry) => entry.name === name);
if (member && source) return positionAt(starts, member.valueStart).line;
return null;
};
for (const key of descriptor.stateKeys) declare(AtlasNodeKind.STATE, key, memberLine(key));
for (const getter of descriptor.getters) declare(AtlasNodeKind.GETTER, getter, memberLine(getter));
const atomicActions = new Set(descriptor.atomicActions || []);
for (const action of descriptor.actions) {
const id = declare(AtlasNodeKind.ACTION, action, memberLine(action));
if (atomicActions.has(action)) {
model.addNode({ id, kind: AtlasNodeKind.ACTION, atomic: true });
}
}
for (const event of descriptor.events || []) declare(AtlasNodeKind.EVENT, event, null);
// A bridge that imports another bridge sees it under its local name.
/** @type {Map<string, object>} */
const imported = new Map();
for (const entry of descriptor.bridgeImports || []) {
const target = bridges && bridges.get(path.resolve(entry.resolved));
if (!target) continue;
imported.set(entry.local, { descriptor: target });
const targetNode = nodeId(AtlasNodeKind.BRIDGE, null, target.name);
model.addNode({ id: targetNode, kind: AtlasNodeKind.BRIDGE, name: target.name });
model.addEdge({
from: ownerId,
to: targetNode,
kind: AtlasEdgeKind.IMPORTS,
confidence: Confidence.CERTAIN,
loc: { file },
});
}
if (!source) return ownerId;
const scopeFor = (locals) => ({
ownerId,
ownerKind: AtlasNodeKind.BRIDGE,
selfBridge: descriptor,
bridges: imported,
locals,
state: new Set(descriptor.stateKeys),
computed: new Set(descriptor.getters),
actions: new Set(descriptor.actions),
});
for (const member of descriptor.members || []) {
if (!member.body) continue;
const body = source.slice(member.body.start, member.body.end);
const line = positionAt(starts, member.valueStart).line;
let from;
if (member.kind === 'getter') from = nodeId(AtlasNodeKind.GETTER, ownerId, member.name);
else if (member.kind === 'action') from = nodeId(AtlasNodeKind.ACTION, ownerId, member.name);
else continue;
// A member's parameters are declared in its signature, which sits outside
// the body span. Without them every parameter reads as an unknown
// identifier, and every unknown identifier blocks a diagnostic.
const params = memberParams(source, member.body.start);
const paramNames = params.trim() ? patternBindings(`[${params}]`).names : [];
analyzeExpression(model, {
from,
code: body,
scope: scopeFor(undefined),
extraLocals: paramNames,
loc: { file, line },
});
if (member.kind === 'action') {
recordRewindEffects(model, from, body, positionAt(starts, member.body.start).line);
}
// `this.emit('changed', …)` inside an action is the action emitting.
const emitRegex = /\bemit\s*\(\s*['"]([^'"]+)['"]/g;
let emitMatch;
while ((emitMatch = emitRegex.exec(body)) !== null) {
const eventNode = nodeId(AtlasNodeKind.EVENT, ownerId, emitMatch[1]);
if (model.hasNode(eventNode)) {
model.addEdge({
from,
to: eventNode,
kind: AtlasEdgeKind.EMITS,
confidence: Confidence.CERTAIN,
loc: { file, line: positionAt(starts, member.body.start + emitMatch.index).line },
});
}
}
}
return ownerId;
}
/**
* Adds a component or page and everything it declares to the model.
* @param {object} model - The AppModel being built.
* @param {object} unit - Everything ComponentParser already extracted.
* @param {string} unit.name - The class name.
* @param {string} unit.kind - `component` or `page`.
* @param {string} unit.filePath - Absolute path to the source file.
* @param {string} unit.rootDir - The project root.
* @param {string} unit.content - The original source, before any transformation.
* @param {object} unit.state - Declared state.
* @param {object} unit.computed - Computed expressions by name.
* @param {object} unit.methods - Action bodies by name.
* @param {object} unit.resources - Resource declarations by name.
* @param {Set<string>} [unit.contracts] - Declared contracts.
* @param {Object<string, object>} [unit.actionModifiers] - Rewind modifiers by action name.
* @param {Array<object>} [unit.bridgeBindings] - Imported bridges.
* @param {Map<string, object>} [unit.bridges] - Every bridge, by absolute path.
* @returns {string} The unit's node id.
*/
export function addComponentUnit(model, unit) {
const { name, filePath, rootDir, content } = unit;
const kind = unit.kind === AtlasNodeKind.PAGE ? AtlasNodeKind.PAGE : AtlasNodeKind.COMPONENT;
const file = relativePath(filePath, rootDir);
const ownerId = nodeId(kind, null, name);
const starts = lineIndex(content);
// Every declaration's line, from one pass over the file rather than one
// whole-file regex scan per declared name.
const lines = declarationLines(content);
// The mask is the expensive part of reading a template, and both the
// relationship walk and the render-edge pass need it. Build it once and
// carry it on the unit.
const masked = maskDeclarations(content);
model.addNode({
id: ownerId,
kind,
name,
file,
...(unit.contracts && unit.contracts.size > 0 ? { contracts: [...unit.contracts].sort() } : {}),
});
const state = unit.state || {};
const computed = unit.computed || {};
const methods = unit.methods || {};
const resources = unit.resources || {};
/**
* Declares one member of the component.
* @param {string} memberKind - The node kind.
* @param {string} memberName - The member name.
* @param {number|null} line - Its line.
* @param {object} [extra] - Extra node fields.
* @returns {string} The member's node id.
*/
const declare = (memberKind, memberName, line, extra = {}) => {
const id = nodeId(memberKind, ownerId, memberName);
model.addNode({
id,
kind: memberKind,
name: memberName,
owner: ownerId,
loc: { file, ...(line ? { line } : {}) },
...extra,
});
model.addEdge({ from: ownerId, to: id, kind: AtlasEdgeKind.DECLARES, confidence: Confidence.CERTAIN });
return id;
};
for (const key of Object.keys(state)) {
declare(AtlasNodeKind.STATE, key, lines.state.get(key) || null, { initial: state[key] });
}
for (const [computedName, expression] of Object.entries(computed)) {
declare(AtlasNodeKind.COMPUTED, computedName, lines.computed.get(computedName) || null, { expression });
}
const actionModifiers = unit.actionModifiers || {};
for (const actionName of Object.keys(methods)) {
const modifier = actionModifiers[actionName];
declare(
AtlasNodeKind.ACTION,
actionName,
lines.action.get(actionName) || null,
modifier && modifier.atomic
? { atomic: true, ...(modifier.onConflict ? { onConflict: modifier.onConflict } : {}) }
: {},
);
}
for (const resourceName of Object.keys(resources)) {
declare(AtlasNodeKind.RESOURCE, resourceName, lines.resource.get(resourceName) || null);
}
/** @type {Map<string, object>} */
const importedBridges = new Map();
for (const binding of unit.bridgeBindings || []) {
const descriptor = findBridgeDescriptor(unit.bridges, binding.bridge);
if (!descriptor) continue;
importedBridges.set(binding.local, { descriptor });
const bridgeNode = nodeId(AtlasNodeKind.BRIDGE, null, descriptor.name);
model.addNode({ id: bridgeNode, kind: AtlasNodeKind.BRIDGE, name: descriptor.name });
model.addEdge({
from: ownerId,
to: bridgeNode,
kind: AtlasEdgeKind.IMPORTS,
confidence: Confidence.CERTAIN,
loc: { file, line: lineOf(content, new RegExp(`import\\s+${escapeName(binding.local)}\\b`)) || 1 },
});
}
const baseScope = {
ownerId,
ownerKind: kind,
state: new Set(Object.keys(state)),
computed: new Set(Object.keys(computed)),
actions: new Set(Object.keys(methods)),
resources: new Set(Object.keys(resources)),
bridges: importedBridges,
slotProps: new Set(),
loopVars: new Map(),
};
for (const [computedName, expression] of Object.entries(computed)) {
analyzeExpression(model, {
from: nodeId(AtlasNodeKind.COMPUTED, ownerId, computedName),
code: expression,
scope: baseScope,
loc: { file, line: lines.computed.get(computedName) || 1 },
});
}
for (const [actionName, body] of Object.entries(methods)) {
const actionId = nodeId(AtlasNodeKind.ACTION, ownerId, actionName);
analyzeExpression(model, {
from: actionId,
code: body,
scope: baseScope,
loc: { file, line: lines.action.get(actionName) || 1 },
});
recordRewindEffects(model, actionId, body, lines.action.get(actionName) || 1);
recordSubscriptions(model, {
from: nodeId(AtlasNodeKind.ACTION, ownerId, actionName),
body,
importedBridges,
file,
line: lines.action.get(actionName) || 1,
});
}
for (const [resourceName, declaration] of Object.entries(resources)) {
const handler = typeof declaration === 'string' ? declaration : declaration && declaration.handler;
analyzeExpression(model, {
from: nodeId(AtlasNodeKind.RESOURCE, ownerId, resourceName),
code: handler,
scope: baseScope,
loc: { file, line: lines.resource.get(resourceName) || 1 },
});
}
addTemplateRelationships(model, { ...unit, ownerId, kind, file, starts, masked, baseScope });
return { ownerId, masked, starts };
}
/**
* Records `bridge.on('event', …)` subscriptions.
*
* The compiler already extracts these to warn about events a bridge never
* emits (AVX_W38). Atlas keeps the relationship rather than only the warning.
* @param {object} model - The AppModel being built.
* @param {object} options - `{from, body, importedBridges, file, line}`.
* @returns {void}
*/
function recordSubscriptions(model, options) {
const { from, body, importedBridges, file, line } = options;
for (const { target, event } of extractSubscriptions(body)) {
const bridge = importedBridges.get(target);
if (!bridge) continue;
const eventNode = nodeId(
AtlasNodeKind.EVENT,
nodeId(AtlasNodeKind.BRIDGE, null, bridge.descriptor.name),
event,
);
if (!model.hasNode(eventNode)) {
model.addUnresolved({
reason: UnresolvedReason.UNKNOWN_BRIDGE_MEMBER,
expr: `${target}.on('${event}')`,
name: `${bridge.descriptor.name}:${event}`,
owner: from,
loc: { file, line },
});
continue;
}
model.addEdge({
from,
to: eventNode,
kind: AtlasEdgeKind.SUBSCRIBES,
confidence: Confidence.CERTAIN,
loc: { file, line },
});
}
}
/**
* Finds a bridge descriptor by declared name.
* @param {Map<string, object>|undefined} bridges - Descriptors by absolute path.
* @param {string} name - The bridge's declared name.
* @returns {object|null} The descriptor.
*/
function findBridgeDescriptor(bridges, name) {
if (!bridges) return null;
for (const descriptor of bridges.values()) {
if (descriptor && descriptor.name === name) return descriptor;
}
return null;
}
/**
* Walks a component's template and records what each construct relates to.
*
* Runs over the **masked original source**, so every node's location is the
* line and column the developer wrote, not an offset into a rewritten string.
* @param {object} model - The AppModel being built.
* @param {object} unit - The component context.
* @returns {void}
*/
function addTemplateRelationships(model, unit) {
const { ownerId, file, starts, masked, baseScope } = unit;
const events = collectTemplateEvents(masked);
// Scoped-slot variables come from whichever parent fills the slot, so they
// are named here only to keep them out of the unknown-identifier bucket.
const slotPropsRegex = /data-slot-props\s*=\s*"([^"]*)"/gi;
let slotMatch;
while ((slotMatch = slotPropsRegex.exec(masked)) !== null) {
for (const part of slotMatch[1].split(/[\s,]+/)) {
if (part.trim()) baseScope.slotProps.add(part.trim());
}
}
/** @type {Array<{name: string, previous: object|undefined}>} */
const loopStack = [];
for (const event of events) {
const position = positionAt(starts, event.index);
const loc = { file, line: position.line, column: position.column };
if (event.type === 'loop_start') {
const bindingId = `${AtlasNodeKind.BINDING}:${ownerId}@${position.line}:${position.column}`;
model.addNode({
id: bindingId,
kind: AtlasNodeKind.BINDING,
owner: ownerId,
binding: 'for',
expression: String(event.list).trim(),
loc,
});
model.addEdge({ from: ownerId, to: bindingId, kind: AtlasEdgeKind.DECLARES, confidence: Confidence.CERTAIN });
analyzeExpression(model, { from: bindingId, code: event.list, scope: baseScope, loc });
// The item binding stands for an element of the list, so a later read of
// `item.qty` resolves back to the state key the list came from.
const listRefs = scanReferences(String(event.list));
const first = listRefs.references.find((ref) => ref.kind === 'read');
const resolved = first ? resolveReference(first, baseScope) : null;
// Every name the header binds, not just the first. `[a, b] in pairs`
// binds two, and registering only one left the other unresolvable.
const bindings = event.bindings && event.bindings.length > 0 ? event.bindings : [event.item];
for (const bound of bindings) {
loopStack.push({ name: bound, previous: baseScope.loopVars.get(bound), groupSize: bindings.length });
baseScope.loopVars.set(bound, {
resolved: resolved && resolved.target ? resolved : null,
});
}
// The key is evaluated *inside* the loop -- `key="row.id"` reads the item
// -- so it is analysed after the binding exists. Analysing it first
// reported the loop variable as an unresolved identifier on every keyed
// list, which is the one shape a keyed list always has.
if (event.key) {
analyzeExpression(model, { from: bindingId, code: event.key, scope: baseScope, loc });
}
continue;
}
if (event.type === 'loop_end') {
// One frame per name the header bound, popped in reverse so a shadowed
// outer binding is restored to what it was.
const count = loopStack.length > 0 ? (loopStack[loopStack.length - 1].groupSize || 1) : 0;
for (let i = 0; i < count; i += 1) {
const frame = loopStack.pop();
if (!frame) break;
if (frame.previous) baseScope.loopVars.set(frame.name, frame.previous);
else baseScope.loopVars.delete(frame.name);
}
continue;
}
if (event.type === 'interpolation' || event.type === 'directive') {
const bindingId = `${AtlasNodeKind.BINDING}:${ownerId}@${position.line}:${position.column}`;
model.addNode({
id: bindingId,
kind: AtlasNodeKind.BINDING,
owner: ownerId,
binding: event.type === 'directive' ? event.name : 'text',
expression: String(event.expr).trim(),
loc,
});
model.addEdge({ from: ownerId, to: bindingId, kind: AtlasEdgeKind.DECLARES, confidence: Confidence.CERTAIN });
analyzeExpression(model, {
from: bindingId,
code: event.expr,
scope: baseScope,
loc,
// data-ax-bind is two-way: it renders the value and writes it back.
writeBack: event.name === 'data-ax-bind',
});
continue;
}
if (event.type === 'event') {
const handlerId = `${AtlasNodeKind.HANDLER}:${ownerId}@${position.line}:${position.column}`;
model.addNode({
id: handlerId,
kind: AtlasNodeKind.HANDLER,
owner: ownerId,
event: event.name,
expression: String(event.expr).trim(),
loc,
});
model.addEdge({ from: ownerId, to: handlerId, kind: AtlasEdgeKind.DECLARES, confidence: Confidence.CERTAIN });
analyzeExpression(model, { from: handlerId, code: event.expr, scope: baseScope, loc });
}
}
}
/**
* Records which components a template renders.
*
* Kept separate from the expression walk because the compiler resolves child
* components by class name after every component has been discovered, so this
* runs once the model knows what exists.
* @param {object} model - The AppModel being built.
* @param {object} unit - `{ownerId, content, file, known}` where `known` maps a
* class name to its node id.
* @returns {void}
*/
export function addRenderEdges(model, unit) {
const { ownerId, content, file, known } = unit;
const masked = unit.masked || maskDeclarations(content);
const starts = unit.starts || lineIndex(content);
const tagRegex = /<([A-Z][A-Za-z0-9]*)\b/g;
let match;
while ((match = tagRegex.exec(masked)) !== null) {
const target = known.get(match[1]);
if (!target) {
model.addUnresolved({
reason: UnresolvedReason.DYNAMIC_COMPONENT,
expr: `<${match[1]}>`,
owner: ownerId,
loc: { file, line: positionAt(starts, match.index).line },
});
continue;
}
if (target === ownerId) continue;
model.addEdge({
from: ownerId,
to: target,
kind: AtlasEdgeKind.RENDERS,
confidence: Confidence.CERTAIN,
loc: { file, line: positionAt(starts, match.index).line },
});
}
}
/**
* Whether a node kind owns members.
* @param {string} kind - A node kind.
* @returns {boolean} True for components, pages and bridges.
*/
export function isOwnerKind(kind) {
return OWNER_KINDS.has(kind);
}
export default { addComponentUnit, addBridgeUnit, addRenderEdges, analyzeExpression };