diff --git a/docs/variable-store-plan.md b/docs/variable-store-plan.md new file mode 100644 index 0000000..d5dc978 --- /dev/null +++ b/docs/variable-store-plan.md @@ -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. diff --git a/src/cli/completions/variable-system.test.ts b/src/cli/completions/variable-system.test.ts index cff1e37..2575aac 100644 --- a/src/cli/completions/variable-system.test.ts +++ b/src/cli/completions/variable-system.test.ts @@ -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: [] }); diff --git a/src/components/journal/VariableView.tsx b/src/components/journal/VariableView.tsx index 5ae7910..288e296 100644 --- a/src/components/journal/VariableView.tsx +++ b/src/components/journal/VariableView.tsx @@ -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 (