ADR 0007: Guards live in three layers, and the runtime layer sits behind one dev-mode flag
This page is generated from docs/adr/0007-guards-in-three-layers-behind-one-dev-flag.md by docs:collect. Edit the source, not this page.
Warcraft III scripting pitfalls (desyncs from local-player branches and iteration order, silently killed threads, leaks, use after destroy) are documented across community threads that nobody reads at the moment they matter. We catch them mechanically in three layers: the type system (branded handles, non-null creation, @async markers on the Typings), a published eslint-plugin-reforged with type-aware rules that the Template enables by default and that read machine-readable data shipped with the packages (async-natives.json, renames.json, local-safe.json), and runtime Guards inside the library that are active only in Dev mode: pcall around every callback handed to a Native, assertions on local scope and init phase, a tombstone metatable on destroyed Wrappers, a re-entrancy check on damage handlers, and leak counters. Dev mode is a single flag set by Reforged.configure, generated by the Template from its build mode; the library defaults to off, and every dev-only decision is taken when a callback is registered, so a release build pays nothing per call.
Considered options
- Documentation only for the "context-dependent" pitfalls: rejected by the driving dev; a guard must not depend on the author remembering a page.
- Two published Lua builds (dev and release) instead of a runtime flag: more artefacts and a consumer-side switch for no gain over a registration-time decision.
- Runtime checks on every call (destroyed flag tested in each method): a cost on every call and code in every method; the tombstone metatable gives the same detection for free.
- Biome for the lint layer: rejected in ADR 0002 (syntactic plugins cannot see types or control flow).
Consequences
- The game has no
debuglibrary, so runtime reports carry a message and the Wrapper identity, never a traceback. pairsorder is deterministic per game build but not guaranteed; the lint warns on order-dependent iteration and the library shipsSyncedMap/SyncedSetas the replacement.- Each lint rule needs fixture tests and the three data files are release artefacts; a rename not recorded in
renames.jsonis invisible to the migration rule. - Code that bypasses the library (raw
GetLocalPlayer() == pbranches) is covered by the lint only, not by the runtime scope assertion.
Decision record: https://github.com/phmilk/reforged-ts/issues/16