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:
@@ -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,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={
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user