fix(journal): surface variable eval errors

Collect and report failed declarations, failed tag modifier expressions, and
circular dependencies instead of swallowing them. Diagnostics are deduped,
logged with key/expression context, cleared on re-init, and exposed through
useJournalCompletions and the VariableView warning banner. A bad declaration
no longer aborts the rest of a cascade.

Reject malformed numeric literals in the tokenizer so inputs like "1.2.3"
throw instead of being truncated by parseFloat.
This commit is contained in:
hyper
2026-09-12 22:22:18 +08:00
parent a26d53cbeb
commit 9f372fa2b0
7 changed files with 399 additions and 9 deletions
+117
View File
@@ -40,6 +40,7 @@ import {
getDeclExpr,
extractDependencies,
rebuildReactivityFromStore,
getReactivityDiagnostics,
} from "../../components/journal/var-reactivity";
import { parseInput } from "../../components/journal/command-parser";
import {
@@ -396,6 +397,15 @@ describe("evaluateExpression", () => {
).toThrow("Unexpected end");
});
test("throws on malformed number literals", () => {
expect(() =>
evaluateExpression("1.2.3", { lookup: () => undefined }),
).toThrow("Invalid number literal");
expect(() =>
evaluateExpression("1.", { lookup: () => undefined }),
).toThrow("Invalid number literal");
});
test("handles decimal numbers", () => {
const result = evaluateExpression("3.5 + 2.5", { lookup: () => undefined });
expect(result.value).toEqual({ kind: "number", value: 6 });
@@ -574,6 +584,113 @@ describe("var-reactivity", () => {
});
});
describe("diagnostics", () => {
test("reports a declaration that fails to evaluate", () => {
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
initReactivity({
declarations: [
{ key: "$hp", expression: "10 + 5" },
{ key: "$bad", expression: "#warrior * 2" },
],
tagModifiers: [],
});
const results = computeInitialValues(store());
expect(results.find((r) => r.key === "$hp")?.value).toBe("15");
expect(results.find((r) => r.key === "$bad")).toBeUndefined();
const diags = getReactivityDiagnostics();
expect(diags).toHaveLength(1);
expect(diags[0].kind).toBe("declaration");
expect(diags[0].key).toBe("$bad");
expect(diags[0].expression).toBe("#warrior * 2");
warn.mockRestore();
});
test("dedupes repeated failures of the same expression", () => {
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
initReactivity({
declarations: [{ key: "$bad", expression: "#warrior * 2" }],
tagModifiers: [],
});
computeInitialValues(store());
computeInitialValues(store());
expect(getReactivityDiagnostics()).toHaveLength(1);
warn.mockRestore();
});
test("clears diagnostics on re-init", () => {
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
initReactivity({
declarations: [{ key: "$bad", expression: "#warrior * 2" }],
tagModifiers: [],
});
computeInitialValues(store());
expect(getReactivityDiagnostics()).toHaveLength(1);
initReactivity({ declarations: [], tagModifiers: [] });
expect(getReactivityDiagnostics()).toHaveLength(0);
warn.mockRestore();
});
test("records a diagnostic when declarations are circular", () => {
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
expect(() =>
initReactivity({
declarations: [
{ key: "$a", expression: "$b + 1" },
{ key: "$b", expression: "$a + 1" },
],
tagModifiers: [],
}),
).toThrow();
const diags = getReactivityDiagnostics();
expect(diags).toHaveLength(1);
expect(diags[0].kind).toBe("init");
warn.mockRestore();
});
test("reports a tag modifier that fails to evaluate", () => {
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$hp", expression: "#x * 2", threshold: 1 },
],
});
setBase("$class", "#warrior:1");
computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
const diags = getReactivityDiagnostics();
expect(diags).toHaveLength(1);
expect(diags[0].kind).toBe("modifier");
expect(diags[0].key).toBe("$hp");
expect(diags[0].tag).toBe("#warrior");
expect(diags[0].source).toBe("$class");
warn.mockRestore();
});
test("keeps cascading other dependents when a declaration fails", () => {
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
initReactivity({
declarations: [
{ key: "$bad", expression: "$con * #warrior" },
{ key: "$good", expression: "$con + 1" },
],
tagModifiers: [],
});
const cascade = computeCascade("$con", undefined, store({ $con: "5" }));
expect(cascade.find((r) => r.key === "$good")?.value).toBe("6");
expect(cascade.find((r) => r.key === "$bad")).toBeUndefined();
expect(getReactivityDiagnostics().some((d) => d.key === "$bad")).toBe(true);
warn.mockRestore();
});
});
describe("getCombined / setBase", () => {
test("returns base value when no mods", () => {
initReactivity({ declarations: [], tagModifiers: [] });
+17
View File
@@ -17,6 +17,9 @@ export const VariableView: Component = () => {
const values = createMemo(() => stream.variables);
/** Runtime engine diagnostics (bad declarations / modifiers). */
const diagnostics = createMemo(() => comp.diagnostics());
/** Flat list of all variables */
const items = createMemo(() => {
const vars = values();
@@ -32,6 +35,20 @@ export const VariableView: Component = () => {
return (
<div class="h-full overflow-y-auto bg-gray-50">
<Show when={diagnostics().length > 0}>
<div class="border-b border-amber-200 bg-amber-50 px-3 py-2">
<div class="text-xs font-semibold text-amber-800 mb-1">
变量求值有 {diagnostics().length} 个问题
</div>
<ul class="space-y-1">
<For each={diagnostics()}>
{(d) => (
<li class="text-xs text-amber-700 font-mono break-all">{d.message}</li>
)}
</For>
</ul>
</div>
</Show>
<Show
when={hasAnyData()}
fallback={
+13 -2
View File
@@ -30,7 +30,7 @@ import type {
VarDeclaration,
TagModifier,
} from "../../cli/completions/types";
import { initReactivity, computeInitialValues, rebuildReactivityFromStore } from "./var-reactivity";
import { initReactivity, computeInitialValues, rebuildReactivityFromStore, getReactivityDiagnostics, onReactivityDiagnostics, type ReactivityDiagnostic } from "./var-reactivity";
import {
sendMessage,
journalStreamState,
@@ -58,6 +58,14 @@ const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
status: "loading",
});
// Runtime diagnostics from the variable engine (bad declarations, modifier
// expressions, circular deps). Bridged into a signal so components can render
// them; the engine itself stays Solid-free.
const [reactivityDiagnostics, setReactivityDiagnostics] = createSignal<
ReactivityDiagnostic[]
>(getReactivityDiagnostics());
onReactivityDiagnostics((list) => setReactivityDiagnostics(list));
// The registry backing the completions. Populated in both CLI and client
// modes so inline content ids can be resolved at runtime (e.g. spark rolls).
let activeRegistry: ContentRegistry = { pathIndex: {}, docContent: {} };
@@ -225,13 +233,16 @@ export function ensureCompletions(): Promise<void> {
export function useJournalCompletions(): {
state: CompletionsState;
data: JournalCompletions;
/** Reactive accessor for runtime variable-engine diagnostics. */
diagnostics: () => ReactivityDiagnostic[];
} {
const s = completionsState();
if (s.status === "loaded") {
return { state: s, data: s.data };
return { state: s, data: s.data, diagnostics: reactivityDiagnostics };
}
return {
state: s,
diagnostics: reactivityDiagnostics,
data: {
dice: [],
links: [],
+2 -2
View File
@@ -60,7 +60,7 @@ export { parseDeclareCsv } from "./declare-parser";
export type { VarDeclaration, TagModifier } from "./declare-parser";
export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity";
export type { VarReactivityState, VariableStore } from "./var-reactivity";
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues, getReactivityDiagnostics, clearReactivityDiagnostics, onReactivityDiagnostics } from "./var-reactivity";
export type { VarReactivityState, VariableStore, ReactivityDiagnostic } from "./var-reactivity";
export { JournalContext, useJournalContext } from "./JournalContext";
export type { JournalContextValue } from "./JournalContext";
+111 -5
View File
@@ -56,6 +56,26 @@ export interface ActiveMod {
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
// ---------------------------------------------------------------------------
@@ -133,6 +153,61 @@ const sourceActivations = new Map<string, Array<{
/** 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
// ---------------------------------------------------------------------------
@@ -151,6 +226,7 @@ export function initReactivity(state: VarReactivityState): void {
numericMods.clear();
tagMapMods.clear();
sourceActivations.clear();
clearReactivityDiagnostics();
// Index tag modifiers
for (const tm of state.tagModifiers) {
@@ -176,7 +252,15 @@ export function initReactivity(state: VarReactivityState): void {
}
}
checkCircular();
try {
checkCircular();
} catch (e) {
reportDiagnostic({
kind: "init",
message: `Declarations contain an unresolvable dependency: ${errorText(e)}`,
});
throw e;
}
}
/** Extract $var names from an expression string. */
@@ -400,8 +484,13 @@ export function computeInitialValues(
if (!results.some((x) => x.key === key)) {
results.push({ key, value: combined, base: rawValue });
}
} catch {
// skip failed evaluations at init time
} catch (e) {
reportDiagnostic({
kind: "declaration",
key,
expression: expr,
message: `Declaration ${key} failed to evaluate "${expr}": ${errorText(e)}`,
});
}
}
@@ -506,8 +595,17 @@ function applyTagMapActivations(
} else {
results.push({ key: mod.target, value: combined, base });
}
} catch {
// skip failed modifier
} 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
@@ -635,6 +733,14 @@ function reevaluateDependents(
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);
}
@@ -17,6 +17,7 @@
* - Division by zero
* - Unknown functions
* - Malformed expressions
* - Malformed numeric literals (e.g. "1.2.3")
*/
import { rollFormula } from "../md-commander/hooks";
@@ -168,6 +169,9 @@ interface Token {
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
const DICE_RE = /^\d*d\d+(?:[kdh]\d+)*$/i;
/** Numeric literal: integer or decimal with digits on both sides of the dot. */
const NUMBER_RE = /^\d+(?:\.\d+)?$/;
/** Single tagmap entry: "#warrior" or "#warrior:1" */
const TAGMAP_ENTRY_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*)(?::(\d+))?/;
@@ -205,6 +209,12 @@ function tokenize(input: string): Token[] {
tokens.push({ kind: "number", value: String(rollDice(dice)), raw: dice });
continue;
}
// The scanner above consumes digits and dots greedily, so "1.2.3" and
// "1." arrive here as a single chunk. Reject them rather than letting
// parseFloat silently truncate to a wrong value.
if (!NUMBER_RE.test(num)) {
throw new Error(`Invalid number literal: "${num}"`);
}
tokens.push({ kind: "number", value: num, raw: num });
continue;
}