Files
ttrpg-tools/src/components/journal/var-reactivity.ts
T
hyper 3532be2335 fix(journal): re-publish only changed declarations
topoSortAffected followed declared dependencies even when they were not in the affected set, so reevaluateDependents re-evaluated and re-published variables that had not changed. This could also overwrite a value just set via /set with the declaration's own expression.
2026-09-12 23:44:06 +08:00

860 lines
28 KiB
TypeScript

/**
* Variable reactivity engine — tracks variable declarations and tag
* modifiers, then cascades changes when variables are set.
*
* Runs client-side (in the sender's tab, via command-dispatcher).
* Uses a base/mod separation:
* - baseValues: what /set writes (or declaration evaluation produces)
* - numericMods: per-target list of {tag, value, source} from tag activations
* - tagMapMods: per-target list of {tag, value, source} for tagmap targets
* - sourceActivations: per-source list of {tag, target, value, threshold, kind}
* for deactivation
*
* Variables have one of two types:
* - numeric: base + sum(numericMods)
* - tagmap (#warrior:1;#druid:2): the tagmap is used for threshold-gated
* modifier activation; tagmap variables do not receive numeric mods but
* can receive tagmap mods (adding/subtracting to specific tag counts).
*
* All functions that resolve variable values accept an explicit `fallback`
* (the stream VariableStore) rather than relying on mutable module state.
* This ensures correctness regardless of call order.
*/
import type { VarDeclaration, TagModifier } from "./declare-parser";
import { evaluateExpression, exprValueToString } from "./variable-expression";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface VarReactivityState {
declarations: VarDeclaration[];
tagModifiers: TagModifier[];
}
export type VariableStore = Record<string, string>;
/**
* A variable update produced by the engine: the combined `value` to publish
* for display, plus the raw `base` it was derived from. The base is carried on
* the stream so a hydrated client can rebuild the engine without having to
* reverse-engineer base values out of combined ones.
*/
export interface VarChange {
key: string;
value: string;
base: string;
}
/** A single modifier applied to a target variable. */
export interface ActiveMod {
tag: string; // "#warrior"
value: number; // evaluated modifier expression result
source: string; // "$class" — which variable activated this tag
threshold: number; // the threshold that triggered this activation
kind: "numeric" | "tagmap";
}
/**
* A problem detected while evaluating declarations or tag modifiers at
* runtime. These are benign to the engine (the offending entry is skipped) but
* signal an authoring mistake that would otherwise silently produce a wrong
* value, so they are collected for surfacing in the UI.
*/
export interface ReactivityDiagnostic {
kind: "init" | "declaration" | "modifier";
/** Human-readable description, already including the offending context. */
message: string;
/** The variable the diagnostic concerns (e.g. "$hp"). */
key?: string;
/** The expression that failed to evaluate. */
expression?: string;
/** For modifiers: the tag whose activation triggered the modifier ("#warrior"). */
tag?: string;
/** For modifiers: the variable whose tagmap activated the tag ("$class"). */
source?: string;
}
// ---------------------------------------------------------------------------
// Tagmap helpers
// ---------------------------------------------------------------------------
/** Tagmap serialization format: "#warrior:1;#druid:2" */
const TAGMAP_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*):(\d+)$/;
/** Parse a tagmap string. Returns null if the value is not a valid tagmap. */
function parseTagMap(value: string | undefined): Record<string, number> | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed.startsWith("#")) return null;
const parts = trimmed.split(";");
const map: Record<string, number> = {};
for (const part of parts) {
const m = TAGMAP_RE.exec(part.trim());
if (!m) return null; // invalid entry
const tag = "#" + m[1];
const count = parseInt(m[2], 10);
if (count <= 0) continue; // skip zero/negative counts on parse
map[tag] = (map[tag] ?? 0) + count;
}
return Object.keys(map).length > 0 ? map : null;
}
/** Serialize a tagmap back to string. Returns empty string if map is empty. */
function tagMapToString(map: Record<string, number>): string {
const entries = Object.entries(map).filter(([, c]) => c > 0);
if (entries.length === 0) return "";
return entries.map(([tag, count]) => `${tag}:${count}`).join(";");
}
/** Check if a value is a tagmap string. */
function isTagMapValue(value: string | undefined): boolean {
if (!value) return false;
const trimmed = value.trim();
if (!trimmed.startsWith("#")) return false;
return parseTagMap(trimmed) !== null;
}
// ---------------------------------------------------------------------------
// Internal state
// ---------------------------------------------------------------------------
/** Dependency graph: $dep → Set<$declaredVar> */
let depGraph: Map<string, Set<string>> | null = null;
/** Reverse: $declaredVar → its expression */
let declExprs: Map<string, string> | null = null;
/** Tag modifiers from declare blocks: #tag → [{target, expression, threshold}] */
let tagModMap: Map<string, Array<{ target: string; expression: string; threshold: number }>> | null = null;
/** Base values set by /set or declaration evaluation */
const baseValues = new Map<string, string>();
/** Numeric mods per target: $target → [{tag, value, source, ...}] */
const numericMods = new Map<string, ActiveMod[]>();
/** Tagmap mods per target: $target → [{tag, value, source, ...}] */
const tagMapMods = new Map<string, ActiveMod[]>();
/** Activations per source: $source → [{tag, target, value, threshold, kind}] */
const sourceActivations = new Map<string, Array<{
tag: string;
target: string;
value: number;
threshold: number;
kind: "numeric" | "tagmap";
}>>();
/** Set of $vars currently being re-evaluated (cycle guard) */
const inFlight = new Set<string>();
/** Diagnostics collected since the last init, keyed by a stable id to dedupe. */
const diagnostics = new Map<string, ReactivityDiagnostic>();
/** Listeners notified whenever the diagnostic set changes. */
const diagnosticListeners = new Set<(diagnostics: ReactivityDiagnostic[]) => void>();
function errorText(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
function diagnosticId(d: ReactivityDiagnostic): string {
return [d.kind, d.key ?? "", d.expression ?? "", d.source ?? "", d.tag ?? "", d.message].join("\u0000");
}
function notifyDiagnostics(): void {
const list = [...diagnostics.values()];
for (const listener of diagnosticListeners) listener(list);
}
/** Record a diagnostic, dedupe by identity, and notify listeners. */
function reportDiagnostic(d: ReactivityDiagnostic): void {
const id = diagnosticId(d);
if (diagnostics.has(id)) return;
diagnostics.set(id, d);
notifyDiagnostics();
console.warn(`[var-reactivity] ${d.message}`);
}
/**
* Diagnostics collected by the engine (bad declarations, modifier expressions,
* circular dependencies). Cleared by `initReactivity` so a re-init reports a
* fresh set. Repeated failures of the same expression are reported once.
*/
export function getReactivityDiagnostics(): ReactivityDiagnostic[] {
return [...diagnostics.values()];
}
/** Remove all collected diagnostics. */
export function clearReactivityDiagnostics(): void {
if (diagnostics.size === 0) return;
diagnostics.clear();
notifyDiagnostics();
}
/**
* Subscribe to diagnostic changes. The listener receives the current list on
* every change. Returns an unsubscribe function.
*/
export function onReactivityDiagnostics(
listener: (diagnostics: ReactivityDiagnostic[]) => void,
): () => void {
diagnosticListeners.add(listener);
return () => diagnosticListeners.delete(listener);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Initialize (or re-initialize) the reactivity engine from declarations
* and tag modifiers parsed from role=declare blocks.
*
* The new state is built into locals and only committed once validation
* passes, so a throw (e.g. a circular dependency) leaves the previously
* initialized engine intact rather than half-populated.
*
* Throws if a circular dependency is detected.
*/
export function initReactivity(state: VarReactivityState): void {
const nextTagModMap = new Map<
string,
Array<{ target: string; expression: string; threshold: number }>
>();
const nextDeclExprs = new Map<string, string>();
const nextDepGraph = new Map<string, Set<string>>();
// Index tag modifiers
for (const tm of state.tagModifiers) {
let list = nextTagModMap.get(tm.tag);
if (!list) {
list = [];
nextTagModMap.set(tm.tag, list);
}
list.push({ target: tm.target, expression: tm.expression, threshold: tm.threshold });
}
// Index declarations and build dependency graph
for (const decl of state.declarations) {
nextDeclExprs.set(decl.key, decl.expression);
const deps = extractDependencies(decl.expression);
for (const dep of deps) {
let dependents = nextDepGraph.get(dep);
if (!dependents) {
dependents = new Set();
nextDepGraph.set(dep, dependents);
}
dependents.add(decl.key);
}
}
try {
checkCircular(nextDeclExprs);
} catch (e) {
reportDiagnostic({
kind: "init",
message: `Declarations contain an unresolvable dependency: ${errorText(e)}`,
});
throw e;
}
// Validation passed — commit the new configuration and reset runtime state.
depGraph = nextDepGraph;
declExprs = nextDeclExprs;
tagModMap = nextTagModMap;
baseValues.clear();
numericMods.clear();
tagMapMods.clear();
sourceActivations.clear();
inFlight.clear();
clearReactivityDiagnostics();
}
/** Extract $var names from an expression string. */
export function extractDependencies(expr: string): string[] {
const vars: string[] = [];
const re = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(expr)) !== null) {
const name = "$" + m[1];
if (!vars.includes(name)) vars.push(name);
}
return vars;
}
/**
* Get the combined value of a variable.
* - For tagmap variables: returns the serialized tagmap (base + tagmap mods).
* - For numeric variables: returns base + sum(numericMods).
* Falls back to the provided stream store for variables not tracked locally.
*/
export function getCombined(key: string, fallback?: VariableStore): string {
const base = baseValues.get(key);
if (base !== undefined && isTagMapValue(base)) {
// Tagmap variable: apply tagmap mods, then serialize
const baseMap = parseTagMap(base);
const mods = tagMapMods.get(key) ?? [];
if (baseMap && mods.length === 0) return base;
const resultMap: Record<string, number> = { ...(baseMap ?? {}) };
for (const m of mods) {
resultMap[m.tag] = (resultMap[m.tag] ?? 0) + m.value;
if (resultMap[m.tag] <= 0) delete resultMap[m.tag];
}
const serialized = tagMapToString(resultMap);
return serialized || "0";
}
const baseNum = base !== undefined ? parseFloat(base) : NaN;
const mods = numericMods.get(key) ?? [];
const modSum = mods.reduce((sum, m) => sum + m.value, 0);
if (!isNaN(baseNum)) {
return String(baseNum + modSum);
}
// Fall back to stream store. The stream value is authoritative for
// variables not tracked locally — it already includes any mods from
// the sender's engine, so we return it as-is without adding local mods.
const fb = fallback?.[key];
if (fb !== undefined) return fb;
// No base, but has numeric mods
if (mods.length > 0) return String(modSum);
return "0";
}
/**
* Resolve a `$var` to its combined value, distinguishing an unknown variable
* (undefined) from a real value of "0". Strict contexts such as `/set` and
* `/roll` use this so a typo'd or undeclared reference errors instead of
* silently evaluating to 0.
*/
export function resolveVariable(
key: string,
fallback?: VariableStore,
): string | undefined {
const known =
baseValues.has(key) ||
numericMods.has(key) ||
tagMapMods.has(key) ||
(fallback !== undefined && fallback[key] !== undefined);
return known ? getCombined(key, fallback) : undefined;
}
/**
* Get the effective tagmap for a variable (base + tagmap mods).
* Returns null if the variable is not a tagmap variable.
*/
export function getTagMap(key: string): Record<string, number> | null {
const base = baseValues.get(key);
if (base === undefined || !isTagMapValue(base)) return null;
const baseMap = parseTagMap(base);
if (!baseMap) return null;
const mods = tagMapMods.get(key) ?? [];
const result: Record<string, number> = { ...baseMap };
for (const m of mods) {
result[m.tag] = (result[m.tag] ?? 0) + m.value;
if (result[m.tag] <= 0) delete result[m.tag];
}
return Object.keys(result).length > 0 ? result : null;
}
/** Get the active mods for a variable (for UI hover display). */
export function getMods(key: string): ActiveMod[] {
const nums = numericMods.get(key) ?? [];
const tags = tagMapMods.get(key) ?? [];
return [...nums, ...tags];
}
/** Get the declaration expression for a variable, if any. */
export function getDeclExpr(key: string): string | undefined {
return declExprs?.get(key);
}
/**
* Set the base value for a variable. Called by dispatchSet before
* computing the cascade, so getCombined returns the correct new value.
*/
export function setBase(key: string, value: string): void {
baseValues.set(key, value);
}
/**
* Rebuild local reactivity state (baseValues, mods, sourceActivations)
* from the hydrated stream variable store. Must be called after replayReducers
* so that tag activations are correctly tracked for subsequent cascade
* computations.
*
* The stream store holds *combined* values (base + active modifiers) alongside
* the raw `base` each was derived from, so bases are restored exactly rather
* than reconstructed by subtracting modifiers back out.
*/
export function rebuildReactivityFromStore(
variables: VariableStore,
bases?: VariableStore,
): void {
if (!tagModMap) return;
// Reset, then restore exact bases from the store's base map.
baseValues.clear();
numericMods.clear();
tagMapMods.clear();
sourceActivations.clear();
for (const [key, value] of Object.entries(variables)) {
baseValues.set(key, bases?.[key] ?? value);
}
// Re-attach tag modifiers from each source's *base* tagmap. Using the
// combined value here would re-trigger a modifier that merely added a tag to
// the source (feedback), inflating counts on every rebuild.
for (const [key, value] of Object.entries(variables)) {
const source = bases?.[key] ?? value;
const tagMap = isTagMapValue(source) ? parseTagMap(source) : null;
if (tagMap) applyTagMapActivations(key, {}, tagMap, variables);
}
}
/**
* Compute cascade effects after a variable change.
*
* @param changedVar - the variable that was just set (e.g. "$con")
* @param oldValue - its previous combined value (for tag transition detection)
* @param currentVars - the full stream variable store (as fallback)
* @returns list of {key, value} pairs (combined values) to publish as var messages
*/
export function computeCascade(
changedVar: string,
oldValue: string | undefined,
currentVars: VariableStore,
): VarChange[] {
if (!depGraph || !declExprs || !tagModMap) {
return [];
}
const results: VarChange[] = [];
// ---- Tag activation/deactivation ----
const newValue = getCombined(changedVar, currentVars);
const oldTagMap = isTagMapValue(oldValue) ? parseTagMap(oldValue) : {};
const newTagMap = isTagMapValue(newValue) ? parseTagMap(newValue) : {};
// Diff tagmaps and apply changes
const tagResults = applyTagMapActivations(changedVar, oldTagMap ?? {}, newTagMap ?? {}, currentVars);
results.push(...tagResults);
// ---- Declaration re-evaluation ----
const reevaluated = reevaluateDependents(changedVar, currentVars);
for (const r of reevaluated) {
if (!results.some((x) => x.key === r.key)) {
results.push(r);
}
}
return results;
}
/**
* Compute initial values for all declared variables.
* Called after initReactivity() to seed the store.
*
* @param existing - declared keys already present in the store; these are
* skipped so hydrated/overridden session values are not clobbered.
*/
export function computeInitialValues(
currentVars: VariableStore,
existing?: ReadonlySet<string>,
): VarChange[] {
if (!declExprs) return [];
const allKeys = [...declExprs.keys()];
const sorted = topoSortAffected(new Set(allKeys));
const results: VarChange[] = [];
for (const key of sorted) {
// Variables already in the store were seeded (or overridden) previously;
// recomputing them would clobber session state on every reload.
if (existing?.has(key)) continue;
const expr = declExprs.get(key);
if (!expr) continue;
try {
const result = evaluateExpression(expr, {
// Declarations may reference runtime/external variables; getCombined
// defaults unknown keys to "0" rather than erroring.
lookup: (name) => getCombined(name, currentVars),
});
const rawValue = exprValueToString(result.value);
baseValues.set(key, rawValue);
// Check if this is a tagmap value — if so, activate matching modifiers
const tagMap = isTagMapValue(rawValue) ? parseTagMap(rawValue) : null;
if (tagMap) {
const added = applyTagMapActivations(key, {}, tagMap, currentVars);
for (const r of added) {
if (!results.some((x) => x.key === r.key)) {
results.push(r);
}
}
}
// Always emit the combined value for this key
const combined = getCombined(key, currentVars);
if (!results.some((x) => x.key === key)) {
results.push({ key, value: combined, base: rawValue });
}
} catch (e) {
reportDiagnostic({
kind: "declaration",
key,
expression: expr,
message: `Declaration ${key} failed to evaluate "${expr}": ${errorText(e)}`,
});
}
}
return results;
}
// ---------------------------------------------------------------------------
// Tag activation / deactivation (threshold-based)
// ---------------------------------------------------------------------------
/**
* Apply tagmap changes for a source variable. Compares old and new tagmaps,
* activating/deactivating modifiers whose threshold crossing state changed.
*/
function applyTagMapActivations(
source: string,
oldTagMap: Record<string, number>,
newTagMap: Record<string, number>,
fallback: VariableStore,
): VarChange[] {
if (!tagModMap) return [];
const results: VarChange[] = [];
const allTags = new Set([...Object.keys(oldTagMap), ...Object.keys(newTagMap)]);
for (const tag of allTags) {
const mods = tagModMap.get(tag);
if (!mods || mods.length === 0) continue;
const oldCount = oldTagMap[tag] ?? 0;
const newCount = newTagMap[tag] ?? 0;
for (let modIdx = 0; modIdx < mods.length; modIdx++) {
const mod = mods[modIdx];
const wasActive = oldCount >= mod.threshold;
const isActive = newCount >= mod.threshold;
if (!wasActive && isActive) {
// Activate this modifier
try {
// Snapshot target's current value as base if needed
if (!baseValues.has(mod.target)) {
baseValues.set(mod.target, getCombined(mod.target, fallback));
}
const evalResult = evaluateExpression(mod.expression, {
// Modifier expressions tolerate undefined references (→ "0").
lookup: (name) => getCombined(name, fallback),
});
// Modifier expressions must evaluate to a number
if (evalResult.value.kind !== "number") {
throw new Error(
`Modifier expression "${mod.expression}" must evaluate to a number`,
);
}
const value = evalResult.value.value;
const targetIsTagMap = isTagMapValue(baseValues.get(mod.target));
if (targetIsTagMap) {
// Apply as tagmap mod to the target's tag count
const entry: ActiveMod = { tag, value, source, threshold: mod.threshold, kind: "tagmap" };
let targetMods = tagMapMods.get(mod.target);
if (!targetMods) {
targetMods = [];
tagMapMods.set(mod.target, targetMods);
}
targetMods.push(entry);
} else {
// Apply as numeric mod
const entry: ActiveMod = { tag, value, source, threshold: mod.threshold, kind: "numeric" };
let targetMods = numericMods.get(mod.target);
if (!targetMods) {
targetMods = [];
numericMods.set(mod.target, targetMods);
}
targetMods.push(entry);
}
// Track in source activations for deactivation
let sourceEntries = sourceActivations.get(source);
if (!sourceEntries) {
sourceEntries = [];
sourceActivations.set(source, sourceEntries);
}
sourceEntries.push({
tag,
target: mod.target,
value,
threshold: mod.threshold,
kind: targetIsTagMap ? "tagmap" : "numeric",
});
// Emit new combined value
const combined = getCombined(mod.target, fallback);
const base = baseValues.get(mod.target) ?? combined;
const existing = results.findIndex((r) => r.key === mod.target);
if (existing >= 0) {
results[existing] = { key: mod.target, value: combined, base };
} else {
results.push({ key: mod.target, value: combined, base });
}
} catch (e) {
reportDiagnostic({
kind: "modifier",
key: mod.target,
expression: mod.expression,
tag,
source,
message:
`Modifier ${tag} → ${mod.target} failed to evaluate ` +
`"${mod.expression}" (activated by ${source}): ${errorText(e)}`,
});
}
} else if (wasActive && !isActive) {
// Deactivate this modifier
const sourceEntries = sourceActivations.get(source);
if (!sourceEntries) continue;
const idx = sourceEntries.findIndex(
(e) => e.tag === tag && e.target === mod.target && e.threshold === mod.threshold,
);
if (idx < 0) continue;
const entry = sourceEntries[idx];
sourceEntries.splice(idx, 1);
// Remove from the appropriate mod list
if (entry.kind === "tagmap") {
const targetMods = tagMapMods.get(entry.target);
if (targetMods) {
const modIdx = targetMods.findIndex(
(m) => m.tag === tag && m.source === source && m.threshold === mod.threshold,
);
if (modIdx >= 0) targetMods.splice(modIdx, 1);
if (targetMods.length === 0) tagMapMods.delete(entry.target);
}
} else {
const targetMods = numericMods.get(entry.target);
if (targetMods) {
const modIdx = targetMods.findIndex(
(m) => m.tag === tag && m.source === source && m.threshold === mod.threshold,
);
if (modIdx >= 0) targetMods.splice(modIdx, 1);
if (targetMods.length === 0) numericMods.delete(entry.target);
}
}
// Emit updated combined value
const combined = getCombined(entry.target, fallback);
const base = baseValues.get(entry.target) ?? combined;
const existing = results.findIndex((r) => r.key === entry.target);
if (existing >= 0) {
results[existing] = { key: entry.target, value: combined, base };
} else {
results.push({ key: entry.target, value: combined, base });
}
}
}
}
// Clean up empty sourceActivations
if (sourceActivations.get(source)?.length === 0) {
sourceActivations.delete(source);
}
return results;
}
// ---------------------------------------------------------------------------
// Declaration re-evaluation
// ---------------------------------------------------------------------------
function reevaluateDependents(
changedVar: string,
fallback: VariableStore,
): VarChange[] {
if (!depGraph || !declExprs) return [];
// Collect all dependents reachable from changedVar (BFS)
const affected = new Set<string>();
const queue = [changedVar];
while (queue.length > 0) {
const dep = queue.shift()!;
const dependents = depGraph.get(dep);
if (!dependents) continue;
for (const d of dependents) {
if (!affected.has(d)) {
affected.add(d);
queue.push(d);
}
}
}
const sorted = topoSortAffected(affected);
const results: VarChange[] = [];
for (const key of sorted) {
if (inFlight.has(key)) {
throw new Error(
`Circular dependency detected during evaluation of ${key}`,
);
}
inFlight.add(key);
try {
const expr = declExprs.get(key);
if (!expr) continue;
const result = evaluateExpression(expr, {
// Declaration re-evaluation stays lenient for undeclared references.
lookup: (name) => getCombined(name, fallback),
});
const rawValue = exprValueToString(result.value);
// Check for tagmap transition on this declared variable
const oldCombined = getCombined(key, fallback);
const oldTagMap = isTagMapValue(oldCombined) ? parseTagMap(oldCombined) : {};
const newTagMap = isTagMapValue(rawValue) ? parseTagMap(rawValue) : {};
if (JSON.stringify(oldTagMap) !== JSON.stringify(newTagMap)) {
const tagResults = applyTagMapActivations(key, oldTagMap ?? {}, newTagMap ?? {}, fallback);
for (const r of tagResults) {
if (!results.some((x) => x.key === r.key)) {
results.push(r);
}
}
}
// Update base value
baseValues.set(key, rawValue);
// Emit combined value
const combined = getCombined(key, fallback);
if (!results.some((x) => x.key === key)) {
results.push({ key, value: combined, base: rawValue });
}
} catch (e) {
// Report the bad declaration but keep evaluating unrelated dependents.
reportDiagnostic({
kind: "declaration",
key,
expression: declExprs.get(key),
message: `Declaration ${key} failed to re-evaluate "${declExprs.get(key) ?? ""}": ${errorText(e)}`,
});
} finally {
inFlight.delete(key);
}
}
return results;
}
// ---------------------------------------------------------------------------
// Topological sort & circular check
// ---------------------------------------------------------------------------
function topoSortAffected(affected: Set<string>): string[] {
if (!depGraph || !declExprs) return [...affected];
const result: string[] = [];
const visited = new Set<string>();
const temp = new Set<string>();
function visit(key: string): void {
if (visited.has(key)) return;
if (temp.has(key)) {
throw new Error(`Circular dependency involving ${key}`);
}
temp.add(key);
const expr = declExprs!.get(key);
if (expr) {
const deps = extractDependencies(expr);
for (const dep of deps) {
// Only order within the affected set. Declared dependencies that did
// not change must not be pulled in, or reevaluateDependents would
// re-publish variables that were never touched.
if (affected.has(dep)) {
visit(dep);
}
}
}
temp.delete(key);
visited.add(key);
result.push(key);
}
for (const key of affected) {
visit(key);
}
return result;
}
function checkCircular(exprs: Map<string, string>): void {
const allKeys = [...exprs.keys()];
const state = new Map<string, "unvisited" | "visiting" | "visited">();
for (const k of allKeys) state.set(k, "unvisited");
const path: string[] = [];
function dfs(key: string): void {
const s = state.get(key);
if (s === "visited") return;
if (s === "visiting") {
const cycleStart = path.indexOf(key);
const cycle = path.slice(cycleStart).concat(key);
throw new Error(
`Circular dependency detected: ${cycle.join(" → ")}`,
);
}
state.set(key, "visiting");
path.push(key);
const expr = exprs.get(key);
if (expr) {
const deps = extractDependencies(expr);
for (const dep of deps) {
if (exprs.has(dep)) {
dfs(dep);
}
}
}
path.pop();
state.set(key, "visited");
}
for (const key of allKeys) {
dfs(key);
}
}