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:
@@ -0,0 +1,129 @@
|
|||||||
|
# Variable store — outstanding fixes
|
||||||
|
|
||||||
|
Notes from an audit of the client-side variable / tag reactivity system:
|
||||||
|
|
||||||
|
- `src/components/journal/var-reactivity.ts` — engine (dependency graph, base/mod
|
||||||
|
separation, threshold-gated tag activation, cascade).
|
||||||
|
- `src/components/journal/variable-expression.ts` — expression evaluator.
|
||||||
|
- `src/components/journal/command-dispatcher.ts` — `/set`, `/rolltag`, cascade.
|
||||||
|
- `src/components/journal/completions.ts` — init + seed.
|
||||||
|
- `src/components/stores/journalStream.ts` — replay + hydration rebuild.
|
||||||
|
- `src/components/journal/types/var.tsx` — `var` message + reducer.
|
||||||
|
|
||||||
|
## Already fixed (context)
|
||||||
|
|
||||||
|
Commit `a26d53c` (`fix(journal): keep variable bases in the stream`):
|
||||||
|
|
||||||
|
- Hydration double-counted active tag modifiers because the stream carried only
|
||||||
|
combined values. `var` messages now carry `base` alongside `value`, the store
|
||||||
|
keeps `variableBases`, and `rebuildReactivityFromStore` restores exact bases.
|
||||||
|
- Seeding ran at import against an empty store (clobbering persisted values and
|
||||||
|
appending duplicate `var` messages on every load). It now runs after hydration
|
||||||
|
and only for keys absent from the store.
|
||||||
|
|
||||||
|
The items below are still open.
|
||||||
|
|
||||||
|
## Open issues
|
||||||
|
|
||||||
|
### 1. Modifier and declaration errors are swallowed — ✅ fixed
|
||||||
|
|
||||||
|
- **Where:** `computeInitialValues` (`catch {}`, "skip failed evaluations at init
|
||||||
|
time") and `applyTagMapActivations` (`catch {}`, "skip failed modifier") in
|
||||||
|
`var-reactivity.ts`; `initReactivity` throws are reduced to `console.warn` in
|
||||||
|
`completions.ts`.
|
||||||
|
- **Symptom:** a typo'd declaration or modifier expression silently yields a
|
||||||
|
wrong value (often `0`) with no author-facing feedback.
|
||||||
|
- **Fixed:** the engine now collects `ReactivityDiagnostic` entries for failed
|
||||||
|
declarations (`computeInitialValues`, `reevaluateDependents`), failed modifier
|
||||||
|
expressions (`applyTagMapActivations`), and circular declarations
|
||||||
|
(`initReactivity`). They are deduped, logged via `console.warn` with
|
||||||
|
key/expression/tag context, cleared on each `initReactivity`, and bridged into
|
||||||
|
a signal exposed as `useJournalCompletions().diagnostics()`. `VariableView`
|
||||||
|
renders them as a warning banner. A bad declaration no longer aborts the rest
|
||||||
|
of a cascade.
|
||||||
|
|
||||||
|
### 2. `var del` does not reach the engine — medium
|
||||||
|
|
||||||
|
- **Where:** reducer in `types/var.tsx` supports `action: "del"`; the parser
|
||||||
|
(`command-parser.ts`) has no `/del`; `var-reactivity.ts` never clears
|
||||||
|
`baseValues` and `rebuildReactivityFromStore` never removes stale keys.
|
||||||
|
- **Symptom:** a deleted variable still resolves locally via `getCombined`,
|
||||||
|
because it reads `baseValues` first.
|
||||||
|
- **Fix:** either wire a `/del` command and clear engine state (base + mods) on
|
||||||
|
delete, or drop `del` from the schema/reducer if it is intentionally unused.
|
||||||
|
|
||||||
|
### 3. Reactivity is initialized once, at import — medium
|
||||||
|
|
||||||
|
- **Where:** `bootstrapReactivity` in `completions.ts` runs `initReactivity`
|
||||||
|
once; nothing re-runs it when declare blocks change or a session/document is
|
||||||
|
switched.
|
||||||
|
- **Also:** `DispatchContext.declarations` / `tagModifiers`
|
||||||
|
(`command-dispatcher.ts`) are declared but never read — a vestige of a
|
||||||
|
per-dispatch init that was never wired up.
|
||||||
|
- **Symptom:** editing `role=declare` content requires a full reload; stale
|
||||||
|
graph/expressions persist.
|
||||||
|
- **Fix:** expose a re-init entry point keyed to content/session changes, and
|
||||||
|
either use or remove the unused context fields.
|
||||||
|
|
||||||
|
### 4. `lookup` contract disagrees with its docs — low
|
||||||
|
|
||||||
|
- **Where:** `EvalContext.lookup` documents "Resolve `$var`", but
|
||||||
|
`parseFactor` calls `ctx.lookup(varName.slice(1))` (bare name), and every
|
||||||
|
caller re-adds `$`.
|
||||||
|
- **Symptom:** a caller following the doc would key variables with `$` and
|
||||||
|
silently get `0`s.
|
||||||
|
- **Fix:** update the doc comment to state the argument is the bare name (no
|
||||||
|
`$`), or stop stripping in `parseFactor` and update callers.
|
||||||
|
|
||||||
|
### 5. Undefined variables evaluate to `0` — low
|
||||||
|
|
||||||
|
- **Where:** `parseFactor` (`variable-expression.ts`) returns a numeric `0` for
|
||||||
|
an unresolved `$var`.
|
||||||
|
- **Symptom:** `/set $hp $cn*5` (typo) silently sets `0` instead of failing.
|
||||||
|
- **Fix:** consider erroring on unknown variables in `/set` context while
|
||||||
|
keeping lenient behavior for display/roll contexts, or warn.
|
||||||
|
|
||||||
|
### 6. Tokenizer accepts malformed numbers — ✅ fixed
|
||||||
|
|
||||||
|
- **Where:** the number branch of `tokenize` consumes `[0-9.]` greedily, so
|
||||||
|
`1.2.3` becomes one "number" and `parseFloat` truncates it.
|
||||||
|
- **Fixed:** the tokenizer now validates the scanned chunk against
|
||||||
|
`^\d+(\.\d+)?$` and throws `Invalid number literal: "…"` instead of pushing a
|
||||||
|
silently-truncated value. The error propagates through the diagnostics channel
|
||||||
|
(#1) for declarations/modifiers and through `dispatchError` for `/set` and
|
||||||
|
`/roll`.
|
||||||
|
|
||||||
|
### 7. `topoSortAffected` re-evaluates untouched declarations — low
|
||||||
|
|
||||||
|
- **Where:** in `var-reactivity.ts`, the visit condition
|
||||||
|
`affected.has(dep) || declExprs.has(dep)` pulls declared dependencies that are
|
||||||
|
not in `affected` into the result.
|
||||||
|
- **Symptom:** `reevaluateDependents` re-evaluates and re-publishes variables
|
||||||
|
that did not change. Mostly harmless, but noisy.
|
||||||
|
- **Fix:** only visit/topologically order keys in `affected`.
|
||||||
|
|
||||||
|
### 8. Failed `initReactivity` leaves partial state — low
|
||||||
|
|
||||||
|
- **Where:** `initReactivity` populates `depGraph` / `declExprs` / `tagModMap`
|
||||||
|
before `checkCircular()` throws; `inFlight` is never cleared on re-init.
|
||||||
|
- **Fix:** build into locals and commit on success, or roll back on failure.
|
||||||
|
Clear `inFlight` in `initReactivity`.
|
||||||
|
|
||||||
|
### 9. Residual base / tagmap-target edges — low
|
||||||
|
|
||||||
|
- **Base fallback:** `rebuildReactivityFromStore` falls back to the combined
|
||||||
|
`value` when a message has no `base`, which double-counts mods. In-repo
|
||||||
|
emitters always send `base`; only hand-crafted/external `var` messages hit
|
||||||
|
this.
|
||||||
|
- **Tagmap targets:** rebuild derives activations from each source's *base*
|
||||||
|
tagmap (this fixed a feedback loop where a tagmap-valued target re-triggered
|
||||||
|
its own modifier on every rebuild). If a variable's effective count crosses a
|
||||||
|
threshold purely via mods, that activation is not reconstructed on hydration.
|
||||||
|
Revisit if tagmap-valued modifier targets come into use.
|
||||||
|
|
||||||
|
## Suggested order
|
||||||
|
|
||||||
|
1. #3 (re-init) and #2 (`del`) — correctness of engine lifecycle.
|
||||||
|
2. #1 (diagnostics) — authoring feedback.
|
||||||
|
3. #4–#8 — cleanup and hardening.
|
||||||
|
4. #9 — only if the tagmap-target pattern is adopted.
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
getDeclExpr,
|
getDeclExpr,
|
||||||
extractDependencies,
|
extractDependencies,
|
||||||
rebuildReactivityFromStore,
|
rebuildReactivityFromStore,
|
||||||
|
getReactivityDiagnostics,
|
||||||
} from "../../components/journal/var-reactivity";
|
} from "../../components/journal/var-reactivity";
|
||||||
import { parseInput } from "../../components/journal/command-parser";
|
import { parseInput } from "../../components/journal/command-parser";
|
||||||
import {
|
import {
|
||||||
@@ -396,6 +397,15 @@ describe("evaluateExpression", () => {
|
|||||||
).toThrow("Unexpected end");
|
).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", () => {
|
test("handles decimal numbers", () => {
|
||||||
const result = evaluateExpression("3.5 + 2.5", { lookup: () => undefined });
|
const result = evaluateExpression("3.5 + 2.5", { lookup: () => undefined });
|
||||||
expect(result.value).toEqual({ kind: "number", value: 6 });
|
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", () => {
|
describe("getCombined / setBase", () => {
|
||||||
test("returns base value when no mods", () => {
|
test("returns base value when no mods", () => {
|
||||||
initReactivity({ declarations: [], tagModifiers: [] });
|
initReactivity({ declarations: [], tagModifiers: [] });
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export const VariableView: Component = () => {
|
|||||||
|
|
||||||
const values = createMemo(() => stream.variables);
|
const values = createMemo(() => stream.variables);
|
||||||
|
|
||||||
|
/** Runtime engine diagnostics (bad declarations / modifiers). */
|
||||||
|
const diagnostics = createMemo(() => comp.diagnostics());
|
||||||
|
|
||||||
/** Flat list of all variables */
|
/** Flat list of all variables */
|
||||||
const items = createMemo(() => {
|
const items = createMemo(() => {
|
||||||
const vars = values();
|
const vars = values();
|
||||||
@@ -32,6 +35,20 @@ export const VariableView: Component = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="h-full overflow-y-auto bg-gray-50">
|
<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
|
<Show
|
||||||
when={hasAnyData()}
|
when={hasAnyData()}
|
||||||
fallback={
|
fallback={
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import type {
|
|||||||
VarDeclaration,
|
VarDeclaration,
|
||||||
TagModifier,
|
TagModifier,
|
||||||
} from "../../cli/completions/types";
|
} from "../../cli/completions/types";
|
||||||
import { initReactivity, computeInitialValues, rebuildReactivityFromStore } from "./var-reactivity";
|
import { initReactivity, computeInitialValues, rebuildReactivityFromStore, getReactivityDiagnostics, onReactivityDiagnostics, type ReactivityDiagnostic } from "./var-reactivity";
|
||||||
import {
|
import {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
journalStreamState,
|
journalStreamState,
|
||||||
@@ -58,6 +58,14 @@ const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
|
|||||||
status: "loading",
|
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
|
// 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).
|
// modes so inline content ids can be resolved at runtime (e.g. spark rolls).
|
||||||
let activeRegistry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
let activeRegistry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
||||||
@@ -225,13 +233,16 @@ export function ensureCompletions(): Promise<void> {
|
|||||||
export function useJournalCompletions(): {
|
export function useJournalCompletions(): {
|
||||||
state: CompletionsState;
|
state: CompletionsState;
|
||||||
data: JournalCompletions;
|
data: JournalCompletions;
|
||||||
|
/** Reactive accessor for runtime variable-engine diagnostics. */
|
||||||
|
diagnostics: () => ReactivityDiagnostic[];
|
||||||
} {
|
} {
|
||||||
const s = completionsState();
|
const s = completionsState();
|
||||||
if (s.status === "loaded") {
|
if (s.status === "loaded") {
|
||||||
return { state: s, data: s.data };
|
return { state: s, data: s.data, diagnostics: reactivityDiagnostics };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
state: s,
|
state: s,
|
||||||
|
diagnostics: reactivityDiagnostics,
|
||||||
data: {
|
data: {
|
||||||
dice: [],
|
dice: [],
|
||||||
links: [],
|
links: [],
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export { parseDeclareCsv } from "./declare-parser";
|
|||||||
export type { VarDeclaration, TagModifier } from "./declare-parser";
|
export type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||||
export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
|
export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
|
||||||
export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";
|
export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";
|
||||||
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity";
|
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues, getReactivityDiagnostics, clearReactivityDiagnostics, onReactivityDiagnostics } from "./var-reactivity";
|
||||||
export type { VarReactivityState, VariableStore } from "./var-reactivity";
|
export type { VarReactivityState, VariableStore, ReactivityDiagnostic } from "./var-reactivity";
|
||||||
export { JournalContext, useJournalContext } from "./JournalContext";
|
export { JournalContext, useJournalContext } from "./JournalContext";
|
||||||
export type { JournalContextValue } from "./JournalContext";
|
export type { JournalContextValue } from "./JournalContext";
|
||||||
|
|||||||
@@ -56,6 +56,26 @@ export interface ActiveMod {
|
|||||||
kind: "numeric" | "tagmap";
|
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 helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -133,6 +153,61 @@ const sourceActivations = new Map<string, Array<{
|
|||||||
/** Set of $vars currently being re-evaluated (cycle guard) */
|
/** Set of $vars currently being re-evaluated (cycle guard) */
|
||||||
const inFlight = new Set<string>();
|
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
|
// Public API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -151,6 +226,7 @@ export function initReactivity(state: VarReactivityState): void {
|
|||||||
numericMods.clear();
|
numericMods.clear();
|
||||||
tagMapMods.clear();
|
tagMapMods.clear();
|
||||||
sourceActivations.clear();
|
sourceActivations.clear();
|
||||||
|
clearReactivityDiagnostics();
|
||||||
|
|
||||||
// Index tag modifiers
|
// Index tag modifiers
|
||||||
for (const tm of state.tagModifiers) {
|
for (const tm of state.tagModifiers) {
|
||||||
@@ -176,7 +252,15 @@ export function initReactivity(state: VarReactivityState): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
checkCircular();
|
checkCircular();
|
||||||
|
} catch (e) {
|
||||||
|
reportDiagnostic({
|
||||||
|
kind: "init",
|
||||||
|
message: `Declarations contain an unresolvable dependency: ${errorText(e)}`,
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract $var names from an expression string. */
|
/** Extract $var names from an expression string. */
|
||||||
@@ -400,8 +484,13 @@ export function computeInitialValues(
|
|||||||
if (!results.some((x) => x.key === key)) {
|
if (!results.some((x) => x.key === key)) {
|
||||||
results.push({ key, value: combined, base: rawValue });
|
results.push({ key, value: combined, base: rawValue });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// skip failed evaluations at init time
|
reportDiagnostic({
|
||||||
|
kind: "declaration",
|
||||||
|
key,
|
||||||
|
expression: expr,
|
||||||
|
message: `Declaration ${key} failed to evaluate "${expr}": ${errorText(e)}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,8 +595,17 @@ function applyTagMapActivations(
|
|||||||
} else {
|
} else {
|
||||||
results.push({ key: mod.target, value: combined, base });
|
results.push({ key: mod.target, value: combined, base });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// skip failed modifier
|
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) {
|
} else if (wasActive && !isActive) {
|
||||||
// Deactivate this modifier
|
// Deactivate this modifier
|
||||||
@@ -635,6 +733,14 @@ function reevaluateDependents(
|
|||||||
if (!results.some((x) => x.key === key)) {
|
if (!results.some((x) => x.key === key)) {
|
||||||
results.push({ key, value: combined, base: rawValue });
|
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 {
|
} finally {
|
||||||
inFlight.delete(key);
|
inFlight.delete(key);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
* - Division by zero
|
* - Division by zero
|
||||||
* - Unknown functions
|
* - Unknown functions
|
||||||
* - Malformed expressions
|
* - Malformed expressions
|
||||||
|
* - Malformed numeric literals (e.g. "1.2.3")
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { rollFormula } from "../md-commander/hooks";
|
import { rollFormula } from "../md-commander/hooks";
|
||||||
@@ -168,6 +169,9 @@ interface Token {
|
|||||||
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
|
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
|
||||||
const DICE_RE = /^\d*d\d+(?:[kdh]\d+)*$/i;
|
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" */
|
/** Single tagmap entry: "#warrior" or "#warrior:1" */
|
||||||
const TAGMAP_ENTRY_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*)(?::(\d+))?/;
|
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 });
|
tokens.push({ kind: "number", value: String(rollDice(dice)), raw: dice });
|
||||||
continue;
|
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 });
|
tokens.push({ kind: "number", value: num, raw: num });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user