|
| 1 | +# QuickAdd Macro Variables: Single-Source-of-Truth Refactor Guide |
| 2 | + |
| 3 | +**Audience:** QuickAdd contributors touching macro execution, user scripts, AI helpers, or choice executor plumbing. |
| 4 | +**Goal:** Explain the regression (BUG #995), the Map-backed proxy solution, and how to work safely with shared macro variables going forward. |
| 5 | + |
| 6 | +--- |
| 7 | + |
| 8 | +## 1) Context: How macro variables are shared |
| 9 | + |
| 10 | +- **ChoiceExecutor** owns a `Map<string, unknown> variables` that survives across nested choice executions. |
| 11 | +- **MacroChoiceEngine** exposes `params` to user scripts. Historically, `params.variables` was a plain object copied from the Map after each command and pushed back afterward. |
| 12 | +- **SingleMacroEngine** and **SingleInlineScriptEngine** reuse the same `MacroChoiceEngine` core. |
| 13 | + |
| 14 | +### The old flow (v2.8.0, broken) |
| 15 | +1. Execute command (including user script) using `this.params.variables` (plain object). |
| 16 | +2. After command, pull values from executor Map into `params.variables`. |
| 17 | +3. Then push `params.variables` back into the Map. |
| 18 | + |
| 19 | +This ordering overwrote mutations a script just made with stale Map data. Multiple user scripts in the same macro would all see the first non-null value—BUG #995. |
| 20 | + |
| 21 | +### Requirements we must satisfy |
| 22 | +- **Correctness:** A script’s changes must be immediately visible to the next command. |
| 23 | +- **Safety:** Avoid stale overwrites; handle commands that write directly to the Map (e.g., AI helpers). |
| 24 | +- **Compatibility:** User scripts often destructure `const { variables } = params;` and call helpers like `hasOwnProperty`. |
| 25 | +- **Maintainability:** Minimize sync points and special cases. |
| 26 | + |
| 27 | +--- |
| 28 | + |
| 29 | +## 2) The fix: Map-backed proxy as single source of truth |
| 30 | + |
| 31 | +**Key idea:** Keep one canonical store (the Map). Present an object-like facade (`params.variables`) via a `Proxy` so reads/writes/delete/has/iteration map directly to the Map. No push/pull needed. |
| 32 | + |
| 33 | +File: `src/utils/variablesProxy.ts` |
| 34 | +- Target object includes only a `hasOwnProperty` shim for backward compatibility; no full Object prototype. |
| 35 | +- Proxy traps: |
| 36 | + - `get/set/deleteProperty/has` delegate to the Map. |
| 37 | + - `ownKeys` and `getOwnPropertyDescriptor` make `Object.keys`, `for...in`, `JSON.stringify`, and `Object.assign` work as expected. |
| 38 | + - Symbols are ignored to keep behavior predictable. |
| 39 | + |
| 40 | +MacroChoiceEngine now sets: |
| 41 | +```ts |
| 42 | +this.params.variables = createVariablesProxy(this.choiceExecutor.variables); |
| 43 | +``` |
| 44 | +and removes all pull/push sync logic. |
| 45 | + |
| 46 | +--- |
| 47 | + |
| 48 | +## 3) Variable Map selection & merge rules |
| 49 | + |
| 50 | +Constructor logic (`MacroChoiceEngine`): |
| 51 | +- If a `variables` Map is supplied, use it as the shared map. |
| 52 | +- If the executor already had a Map and a new Map is provided, merge existing keys into the provided Map (don’t drop prior state). |
| 53 | +- Otherwise, reuse executor Map or create a fresh one. |
| 54 | +- Always reassign `choiceExecutor.variables = sharedVariables` so downstream sees the canonical Map. |
| 55 | + |
| 56 | +Rationale: prevent accidental data loss when callers pass a new Map but expect existing executor state to persist. |
| 57 | + |
| 58 | +--- |
| 59 | + |
| 60 | +## 4) AI helper consistency |
| 61 | + |
| 62 | +In `src/quickAddApi.ts`, AI prompt helpers previously did `Object.assign(choiceExecutor.variables, assistantRes)`—a no-op on Map. Now they iterate `Object.entries(assistantRes)` and `set` into the Map. This aligns with the single-source-of-truth model. |
| 63 | + |
| 64 | +--- |
| 65 | + |
| 66 | +## 5) Tests added/updated |
| 67 | + |
| 68 | +- `src/engine/MacroChoiceEngine.entry.test.ts` |
| 69 | + - Regression: sequential user scripts log `undefined, 1, 2, 3` and final `target === 3`. |
| 70 | + - Merge safeguard: existing executor vars are preserved when a custom Map is provided. |
| 71 | +- `src/utils/variablesProxy.test.ts` |
| 72 | + - Read/write/delete, enumeration, external Map mutation visibility. |
| 73 | + - `hasOwnProperty` shim works; `toString` remains undefined (no Object prototype). |
| 74 | + - `for...in`, `Object.assign`, `JSON.stringify` compatibility. |
| 75 | + |
| 76 | +Run: `bun run test` (full suite), `bun run lint`, `bun run build-with-lint`. |
| 77 | + |
| 78 | +--- |
| 79 | + |
| 80 | +## 6) How to extend safely |
| 81 | + |
| 82 | +When adding new commands or helpers that manipulate variables: |
| 83 | +- **Always write through the Map** (`choiceExecutor.variables.set(...)`) or through `params.variables` (proxy will hit the Map). |
| 84 | +- **Do not reintroduce push/pull sync**—the proxy eliminates the need. |
| 85 | +- **Avoid spreading/cloning `params.variables` into plain objects** unless you truly need a snapshot. Remember snapshots will decouple from live Map updates. |
| 86 | +- If you must add helpers that expect Object prototype methods, consider explicit shims instead of restoring the prototype chain. |
| 87 | + |
| 88 | +--- |
| 89 | + |
| 90 | +## 7) Potential pitfalls & how to avoid them |
| 91 | + |
| 92 | +- **Object.assign on Map**: No-op. Always iterate and `set`. |
| 93 | +- **Prototype expectations**: Only `hasOwnProperty` is provided. Other prototype methods (e.g., `toString`) are intentionally absent to avoid prototype pollution. Accessing them should yield `undefined`. |
| 94 | +- **Multiple Maps**: Passing a new Map into engines will merge in prior executor keys, but not vice versa. Be explicit about which Map you want shared. |
| 95 | +- **Serialization**: `JSON.stringify(params.variables)` works because `ownKeys`/descriptors are implemented; Symbol keys are ignored. |
| 96 | + |
| 97 | +--- |
| 98 | + |
| 99 | +## 8) Quick implementation checklist (if you touch this area) |
| 100 | + |
| 101 | +1. Need shared macro vars? Use the existing `choiceExecutor.variables` Map; wrap with `createVariablesProxy` for object-style access. |
| 102 | +2. Writing new helpers? Use `Map.set`/`get`/`delete` or the proxy—never `Object.assign` on a Map. |
| 103 | +3. Adding tests? Cover: |
| 104 | + - Sequential command visibility. |
| 105 | + - External Map mutations seen through proxy. |
| 106 | + - Enumeration and reflection behaviors. |
| 107 | +4. Run `bun run build-with-lint` and `bun run test`. |
| 108 | + |
| 109 | +--- |
| 110 | + |
| 111 | +## 9) Rationale recap |
| 112 | + |
| 113 | +- **Single source of truth** prevents sync races. |
| 114 | +- **Proxy facade** preserves the ergonomic API (`params.variables.foo`) scripts rely on. |
| 115 | +- **Shims, not prototypes** balance backward compatibility with safety. |
| 116 | +- **Map merge** avoids silent data loss when callers swap Maps. |
| 117 | +- **Consistent writes** ensure AI/other helpers don’t bypass the canonical store. |
| 118 | + |
| 119 | +--- |
| 120 | + |
| 121 | +## 10) If you need to change behavior later |
| 122 | + |
| 123 | +- Update `variablesProxy` first; add targeted tests. |
| 124 | +- Keep constructor merge rules documented; adjust tests accordingly. |
| 125 | +- Re-run full suite—performance tests are occasionally spiky; rerun once if a perf check flakes. |
| 126 | + |
| 127 | +--- |
| 128 | + |
| 129 | +## File map for quick navigation |
| 130 | + |
| 131 | +- `src/utils/variablesProxy.ts` — proxy implementation. |
| 132 | +- `src/engine/MacroChoiceEngine.ts` — variable Map selection and params wiring. |
| 133 | +- `src/engine/MacroChoiceEngine.entry.test.ts` — macro regression tests. |
| 134 | +- `src/utils/variablesProxy.test.ts` — proxy behavior tests. |
| 135 | +- `src/quickAddApi.ts` — AI helper variable assignment fixes. |
| 136 | + |
0 commit comments