EvalContext.lookup now receives the full $var name, matching getCombined, setBase, and getMods, and parseFactor throws "Unknown variable" when the lookup returns undefined. /set and /roll use the new resolveVariable() so a typo errors instead of silently evaluating to 0. Declarations and tag modifiers stay lenient (unknown -> 0): the documented $hp = $con*5+$mod_hp pattern depends on external/undeclared variables defaulting to 0.
7.7 KiB
7.7 KiB
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—varmessage + 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.
varmessages now carrybasealongsidevalue, the store keepsvariableBases, andrebuildReactivityFromStorerestores exact bases. - Seeding ran at import against an empty store (clobbering persisted values and
appending duplicate
varmessages 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") andapplyTagMapActivations(catch {}, "skip failed modifier") invar-reactivity.ts;initReactivitythrows are reduced toconsole.warnincompletions.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
ReactivityDiagnosticentries for failed declarations (computeInitialValues,reevaluateDependents), failed modifier expressions (applyTagMapActivations), and circular declarations (initReactivity). They are deduped, logged viaconsole.warnwith key/expression/tag context, cleared on eachinitReactivity, and bridged into a signal exposed asuseJournalCompletions().diagnostics().VariableViewrenders them as a warning banner. A bad declaration no longer aborts the rest of a cascade.
2. var del does not reach the engine — ✅ fixed (dropped)
- Where: reducer in
types/var.tsxsupportsaction: "del"; the parser (command-parser.ts) has no/del;var-reactivity.tsnever clearsbaseValuesandrebuildReactivityFromStorenever removes stale keys. - Symptom: a deleted variable still resolves locally via
getCombined, because it readsbaseValuesfirst. - Fixed:
/delwas never reachable (no parser emits it, and replay does not validate), so thedelaction was removed from thevarschema, render, and reducer. The wire type is nowaction: "set"only; a legacydelmessage in an old stream is a no-op on replay rather than resurrecting a deleted key.
3. Reactivity is initialized once, at import — ✅ fixed
- Where:
bootstrapReactivityincompletions.tsrunsinitReactivityonce; 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=declarecontent requires a full reload; stale graph/expressions persist. - Fixed:
bootstrapReactivityis nowapplyReactivitybehind an idempotentensureReactivity()that reads the live completions signal, compares a signature, and re-initializes only when the declaration set changes.dispatchCommandcalls it before every dispatch, so a content rescan takes effect on the next command without a reload. The unusedDispatchContext.declarations/tagModifiersfields were removed (they were mount-time snapshots and could not be trusted). Session switches were already covered by the hydration rebuild, which now also passesjournalStreamState.variableBasesso bases are restored exactly instead of falling back to combined values.
4. lookup contract disagrees with its docs — ✅ fixed
- Where:
EvalContext.lookupdocuments "Resolve$var", butparseFactorcallsctx.lookup(varName.slice(1))(bare name), and every caller re-adds$. - Symptom: a caller following the doc would key variables with
$and silently get0s. - Fixed:
EvalContext.lookupnow receives the full$varname (including$), consistent with every other variable API (getCombined,setBase,getMods,resolveVariable). Callers no longer add or strip$.
5. Undefined variables evaluate to 0 — ✅ fixed (strict for commands)
- Where:
parseFactor(variable-expression.ts) returns a numeric0for an unresolved$var. - Symptom:
/set $hp $cn*5(typo) silently sets0instead of failing. - Fixed:
parseFactornow throwsUnknown variable: $xwheneverlookupreturnsundefined, andresolveVariable()distinguishes unknown from a real0./setand/rolluse the strict resolver, so typos fail loudly viadispatchError. Declarations and tag modifiers stay lenient on purpose: they resolve unknown references to0throughgetCombined, because the documented pattern ($hp = $con*5+$mod_hp) relies on external/undeclared variables defaulting to 0.
6. Tokenizer accepts malformed numbers — ✅ fixed
- Where: the number branch of
tokenizeconsumes[0-9.]greedily, so1.2.3becomes one "number" andparseFloattruncates it. - Fixed: the tokenizer now validates the scanned chunk against
^\d+(\.\d+)?$and throwsInvalid number literal: "…"instead of pushing a silently-truncated value. The error propagates through the diagnostics channel (#1) for declarations/modifiers and throughdispatchErrorfor/setand/roll.
7. topoSortAffected re-evaluates untouched declarations — low
- Where: in
var-reactivity.ts, the visit conditionaffected.has(dep) || declExprs.has(dep)pulls declared dependencies that are not inaffectedinto the result. - Symptom:
reevaluateDependentsre-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 — ✅ fixed
- Where:
initReactivitypopulatesdepGraph/declExprs/tagModMapbeforecheckCircular()throws;inFlightis never cleared on re-init. - Fixed:
initReactivitynow builds the declaration map, dependency graph, and tag-modifier map into locals and commits them only aftercheckCircularpasses, so a failed (re-)init leaves the previous engine intact. Runtime state (baseValues, mods,sourceActivations) andinFlightare reset on commit.
9. Residual base / tagmap-target edges — low
- Base fallback:
rebuildReactivityFromStorefalls back to the combinedvaluewhen a message has nobase, which double-counts mods. In-repo emitters always sendbase; only hand-crafted/externalvarmessages 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
- #3 (re-init) and #2 (
del) — correctness of engine lifecycle. - #1 (diagnostics) — authoring feedback.
- #4–#8 — cleanup and hardening.
- #9 — only if the tagmap-target pattern is adopted.