v1.4.9: Validation fixes, Profiler fix, Quest link duplicate fix, Ascension fixes

This commit is contained in:
Xurkon
2026-03-26 06:47:54 -05:00
parent 284f0c3794
commit 1fc9727fee
194 changed files with 21008 additions and 743 deletions
+616
View File
@@ -0,0 +1,616 @@
---
paths:
- "**/*.lua"
---
# Lua Coding Style
> This file extends [common/coding-style.md](../common/coding-style.md) with Lua specific content.
## Version Compatibility Matrix
These rules target **universal Lua coverage** across 5.0, 5.1, 5.2, 5.3, and 5.4. When a feature differs between versions, use the portable pattern or guard with a version check.
| Feature | 5.0 | 5.1 | 5.2 | 5.3 | 5.4 | Portable Pattern |
|---------|-----|-----|-----|-----|-----|------------------|
| Length operator `#` | ❌ `table.getn` | ✅ | ✅ | ✅ | ✅ | `table.getn(t)` or `#t` with guard |
| Varargs `...` as expr | ❌ `arg` table | ✅ | ✅ | ✅ | ✅ | See Varargs section |
| `select()` | ❌ | ✅ | ✅ | ✅ | ✅ | Guard: `if select then ... else arg[i]` |
| `math.mod` / `math.fmod` | ✅ `math.mod` | ✅ `math.fmod` | ✅ `math.fmod` | ✅ | ✅ | `math.fmod or math.mod` |
| `string.gmatch` | ❌ `string.gfind` | ✅ | ✅ | ✅ | ✅ | `string.gmatch or string.gfind` |
| `setfenv`/`getfenv` | ✅ | ✅ | ❌ `_ENV` | ❌ | ❌ | Version-gated sandbox |
| `unpack()` global | ✅ | ✅ | ❌ `table.unpack` | ❌ | ❌ | `unpack or table.unpack` |
| `xpcall` extra args | ❌ | ❌ | ✅ | ✅ | ✅ | Wrap in closure for 5.0/5.1 |
| `goto` statement | ❌ | ❌ | ✅ | ✅ | ✅ | Avoid; use early return |
| Integer subtype | ❌ | ❌ | ❌ | ✅ | ✅ | All numbers are doubles in 5.05.2 |
| Bitwise operators | ❌ | ❌ | ✅ `bit32` lib | ✅ native (`bit32` deprecated) | ✅ native | `bit32` lib on 5.2; native ops on 5.3+ |
| `__gc` for tables | ❌ | ❌ | ✅ | ✅ | ✅ | Only for userdata in 5.0/5.1 |
| `__len` for tables | ❌ | ❌ (userdata only) | ✅ | ✅ | ✅ | `rawlen` or custom function; 5.1 `__len` only for userdata |
| `table.move` | ❌ | ❌ | ❌ | ✅ | ✅ | Manual loop |
| `table.foreach`/`foreachi` | ✅ | ✅ (deprecated) | ❌ removed | ❌ | ❌ | Use `pairs`/`ipairs` (5.0+) |
| `table.setn` | ✅ | ✅ (deprecated) | ❌ removed | ❌ | ❌ | Track length manually |
| `table.maxn` | ❌ | ✅ | ✅ (deprecated) | ❌ removed | ❌ | Manual loop over keys |
| `package` table | ❌ `loadlib` | ✅ | ✅ | ✅ | ✅ | Guard `package and package.path` |
| `pcall` extra args | ✅ | ✅ | ✅ | ✅ | ✅ | Universal |
| `coroutine.status` | ✅ | ✅ | ✅ | ✅ | ✅ | Universal |
| `table.pack` | ❌ | ❌ | ✅ | ✅ | ✅ | `{n = select("#", ...), ...}` |
| `rawlen()` | ❌ | ❌ | ✅ | ✅ | ✅ | `rawlen or function(t) return #t end` |
| `loadstring` | ✅ | ✅ | ❌ (deprecated→`load`) | ❌ | ❌ | `loadstring or load` |
| `string.pack/unpack` | ❌ | ❌ | ❌ | ✅ | ✅ | External `struct` lib for older versions |
| `utf8` library | ❌ | ❌ | ❌ | ✅ | ✅ | External `lua-utf8` lib for older |
| `package.loaders` | ❌ | ✅ | ❌→`.searchers` | ❌ | ❌ | `package.loaders or package.searchers` |
| `coroutine.isyieldable` | ❌ | ❌ | ❌ | ✅ | ✅ | Guard: `coroutine.isyieldable and ...` |
| `math.atan2` | ✅ | ✅ | ✅ | ✅ | ✅ | `math.atan(y, x)` — note argument order is (y, x), not (x, y) |
| `math.log10` | ❌ | ✅ | ✅ | ❌ removed | ❌ | `math.log(x, 10)` or `math.log(x) / math.log(10)` |
| `math.pow` | ✅ | ✅ | ✅ | ✅ | ✅ | `x ^ y` (native operator) or `math.pow(x, y)` |
| `math.log(x, base)` | ❌ | ❌ | ✅ | ✅ | ✅ | `math.log(x) / math.log(base)` for portable base |
| Floor division `//` | ❌ | ❌ | ❌ | ✅ | ✅ | `math.floor(a / b)` for 5.05.2 |
| `math.cosh/sinh/tanh` | ❌ | ✅ | ✅ | ❌ deprecated | ❌ | Implement manually or use external lib |
| `math.frexp`/`math.ldexp` | ❌ | ✅ | ✅ | ❌ deprecated | ❌ | `x * 2.0^exp` for ldexp; external lib for frexp |
| `coroutine.close` | ❌ | ❌ | ❌ | ❌ | ✅ | Guard: `coroutine.close and coroutine.close(co)` |
| `warn()` function | ❌ | ❌ | ❌ | ❌ | ✅ | Guard: `if warn then warn(msg) end` |
| `<const>`/`<close>` attrs | ❌ | ❌ | ❌ | ❌ | ✅ | Use `local` without attrs on 5.05.3 |
| `__le` metamethod required | ❌ (derived from `__lt`) | ❌ | ❌ | ❌ | ✅ (must define explicitly) | Always define both `__lt` and `__le` |
| String→number coercion | ✅ auto | ✅ auto | ✅ auto | ✅ auto | ❌ removed from core | Use explicit `tonumber()` for portability |
| Long string nesting `[[]]` | ✅ (nestable) | ❌ (no nesting) | ❌ | ❌ | ❌ | Use `[=[...]=]` for nested long strings |
| `%z` pattern class | ✅ | ✅ | ❌ deprecated | ❌ | ❌ | Use literal `\0` to match the null character (ASCII 0) in patterns (5.2+) |
| `__ipairs` metamethod | ❌ | ❌ | ✅ | ❌ deprecated | ❌ | Avoid; `ipairs` uses raw integer keys |
| Float→string `.0` suffix | ❌ `2.0``"2"` | ❌ | ❌ | ✅ `2.0``"2.0"` | ✅ | Use `string.format` for consistent formatting |
| `print` calls `tostring` | ✅ | ✅ | ✅ | ✅ | ❌ (hardwired) | Use `__tostring` metamethod for custom output |
| `math.random` auto-seeded | ❌ | ❌ | ❌ | ❌ | ✅ (new PRNG) | Call `math.randomseed` explicitly on 5.05.3 |
| `math.log(x, base)` | ❌ | ❌ | ✅ | ✅ | ✅ | `math.log(x) / math.log(base)` for base argument |
| Floor division `//` | ❌ | ❌ | ❌ | ✅ | ✅ | `math.floor(a / b)` for 5.05.2 |
| `io.lines` return count | 1 | 1 | 1 | 1 | 4 (line, extra, line number, error) | Wrap in `(io.lines(...))` to get 1 value |
| `collectgarbage("count")` | N/A | 2 values | 2 values | 1 value | 1 value | Use `math.floor(collectgarbage("count"))` |
### Cross-Version Compatibility Shim
Place at the top of your entry point file to normalize APIs across versions:
```lua
-- compat.lua: Universal Lua 5.05.4 shim
-- Place at top of your entry point; all other files use these locals.
-- Core builtins that moved between versions
local unpack = unpack or table.unpack -- 5.2 moved to table
local getn = table.getn or function(t) return #t end -- 5.0 has no #
local setn = table.setn or function() end -- 5.0 tracks length via setn
local maxn = table.maxn or function(t) -- removed in 5.3
local n = 0
for k in pairs(t) do
if type(k) == "number" and k > n then n = k end
end
return n
end
-- String library renames
local gmatch = string.gmatch or string.gfind -- 5.0 uses gfind
-- Math library renames
local fmod = math.fmod or math.mod -- 5.0 uses math.mod
-- Module system: 5.0 has no package table
local loadlib = package and package.loadlib or loadlib
-- Vararg helpers
local getVarargCount = select -- nil in 5.0
and function(...) return select("#", ...) end -- 5.1+: use select
or function() return arg and arg.n or 0 end -- 5.0: use arg.n
-- Code loading: loadstring renamed to load in 5.2
local loadstring = loadstring or load -- 5.2+ removed loadstring
-- Raw operations: rawlen added in 5.2 (bypasses __len)
local rawlen = rawlen or function(v) return #v end -- fallback uses #
-- table.pack: added in 5.2; polyfill for 5.0/5.1
local table_pack = table.pack or function(...)
return { n = select and select("#", ...) or (arg and arg.n or 0), ... }
end
```
## Formatting
- **StyLua** for code formatting — always run `stylua .` before committing
- **Luacheck** for lints — `luacheck . --no-color` (treat warnings as CI failures)
- 4-space indent (StyLua default)
- Max line width: 120 characters
- Trailing commas in multi-line table constructors (prevents noisy diffs)
## Naming
Follow standard Lua conventions:
- `camelCase` for local variables, functions, method names
- `PascalCase` for modules, "classes" (tables used as classes via metatables)
- `UPPER_SNAKE_CASE` for constants and enum-like values
- `_camelCase` (leading underscore) for private/internal helpers
- `UPPER_SNAKE_CASE` for WoW event strings (`"PLAYER_LOGIN"`, `"QUEST_ACCEPTED"`)
```lua
-- GOOD
local QuestieDB = {} -- PascalCase module
local MAX_RETRIES = 3 -- UPPER_SNAKE constant
local questId = 10141 -- camelCase local
local function _countTable(t) -- _camelCase private helper
```
## Scoping: local Is Non-Negotiable
Every variable MUST be `local` unless explicitly required as a global. Globals leak into `_G`, pollute the namespace, introduce untraceable coupling between files, and cause **ADDON_ACTION_BLOCKED** taint in WoW sandboxes.
```lua
-- BAD: Implicit global — leaks into _G, causes taint
myValue = 10
-- GOOD: Explicit local
local myValue = 10
```
**Global caching**: Cache frequently-called global functions into module-level locals. This provides measurable performance improvement in tight loops (Lua resolves locals via stack slot, globals via hash lookup into `_G`).
```lua
-- Cache at file / module top — BEFORE any function definitions
local type = type
local pairs = pairs
local ipairs = ipairs
local next = next
local tostring = tostring
local tinsert = table.insert
local tremove = table.remove
local tconcat = table.concat
local format = string.format
local floor = math.floor
local max = math.max
local min = math.min
local coroutine = coroutine
local setmetatable = setmetatable
local getmetatable = getmetatable
```
## Immutability
> **Language note**: This rule overrides [common/coding-style.md](../common/coding-style.md)'s strict immutability. Lua tables are mutable by design and creating fresh copies on every update is prohibitively expensive for large data sets (e.g., 40,000+ NPC records). Use immutability where practical; use controlled mutation with clear ownership semantics where performance demands it.
When immutability is practical (configuration, options, small payloads):
```lua
-- GOOD — returns a new table with the field updated
local function withField(original, key, value)
local copy = {}
for k, v in pairs(original) do copy[k] = v end
copy[key] = value
return copy
end
```
When mutation is necessary (hot paths, large tables, WoW frame pools), document ownership:
```lua
-- ACCEPTABLE — mutation of owned table, clearly documented
--- Compiles NPC data into the binary cache. Mutates QuestieDB.npcData in place
--- because copying 40k records per compile is prohibitively expensive.
function QuestieDBCompiler:CompileNPCData(sourceData, targetTable)
for id, data in pairs(sourceData) do
targetTable[id] = self:EncodeRow(data)
end
end
```
Use **read-only proxies** for tables that must not be modified after initialization:
```lua
local function readOnly(t)
return setmetatable({}, {
__index = t,
__newindex = function(_, k, _)
error(format("Attempt to modify read-only table at key: %s", tostring(k)), 2)
end,
-- table.getn for 5.0 compat; # for 5.1+
__len = function() return (table.getn or rawlen or function(x) return #x end)(t) end,
})
end
local QUEST_FLAGS = readOnly({ SHARABLE = 0x0008, DAILY = 0x1000 })
```
## Tables
### Construction
Prefer table literals over sequential assignment. The compiler generates fewer instructions and programmer intent is clearer:
```lua
-- GOOD: Single allocation, clear structure
local config = {
maxRetries = 3,
timeout = 5,
version = "1.4.7",
pluginNames = { "WotLKDB", "TurtleDB" },
}
-- BAD: Multiple allocations, fragmented intent
local config = {}
config.maxRetries = 3
config.timeout = 5
config.version = "1.4.7"
config.pluginNames = {}
config.pluginNames[1] = "WotLKDB"
config.pluginNames[2] = "TurtleDB"
```
### Array Building in Loops
Use `t[#t + 1]` (fastest in Lua 5.1+) or `tinsert`. In Lua 5.0, use `tinsert` or track an index manually since `#` does not exist:
```lua
-- GOOD: Fastest array append (Lua 5.1+)
local results = {}
for id, data in pairs(sourceData) do
results[#results + 1] = data
end
-- GOOD: Works on ALL versions (5.05.4)
local results = {}
for id, data in pairs(sourceData) do
tinsert(results, data)
end
-- GOOD: Manual index tracking (universal, fastest in 5.0)
local results = {}
local n = 0
for id, data in pairs(sourceData) do
n = n + 1
results[n] = data
end
```
### Iteration Patterns
| Use case | Pattern | Lua versions | Notes |
|----------|---------|-------------|-------|
| Sequential array (no holes) | `for i = 1, #t do` | 5.1+ | Fastest; no function call overhead |
| Sequential array (universal) | `for i = 1, table.getn(t) do` | 5.0+ | Use `getn` shim for 5.0 compat |
| Array with value | `for i, v in ipairs(t) do` | 5.0+ | Stops at first `nil` hole |
| Dictionary / sparse table | `for k, v in pairs(t) do` | 5.0+ | Unordered; processes every key |
| Universal / taint-free | `for k, v in next, t do` | 5.0+ | Equivalent to `pairs` but avoids metamethod |
| Empty-check | `if next(t) == nil then` | 5.0+ | Only reliable way |
| Counting elements | Custom `_countTable` function | 5.0+ | `#t`/`table.getn` only counts array part |
```lua
-- Empty-check: ONLY correct way
if next(myTable) == nil then
-- Table is truly empty (no array or hash keys)
end
-- WRONG: Unreliable for dictionaries and sparse arrays
if #myTable == 0 then -- BROKEN on { a = 1, b = 2 }
```
### Holes in Arrays
The `#` operator is **undefined** on sparse arrays (arrays with `nil` gaps). If holes are possible, track length with a counter field or use a dedicated array class.
```lua
-- WRONG: Undefined behavior with holes
local t = { 1, nil, 3 }
print(#t) -- Could be 1 or 3 (implementation-dependent)
-- CORRECT: Track length explicitly
local t = { n = 3; 1, nil, 3 }
for i = 1, t.n do print(t[i]) end
```
### Table Recycling and Wipe
Reuse tables to reduce GC pressure in hot paths. Use `wipe()` (WoW) or manual niling:
```lua
-- WoW environment: wipe() clears all keys
wipe(myTable)
-- Standard Lua: manual clear
for k in pairs(myTable) do myTable[k] = nil end
```
## Functions
- Keep functions under 50 lines. Extract helpers prefixed with `_`.
- **Return early** to flatten nesting. Lua has no guard clauses, so explicit early returns are the idiomatic substitute.
- Avoid deep nesting (> 4 levels). Refactor into helper functions.
```lua
-- BAD: Deep nesting
function processQuest(questId)
if questId then
local data = QuestieDB.questData[questId]
if data then
local name = data[1]
if name then
-- 4 levels deep...
end
end
end
end
-- GOOD: Early returns flatten the code
function processQuest(questId)
if not questId then return end
local data = QuestieDB.questData[questId]
if not data then return end
local name = data[1]
if not name then return end
-- Proceed at 1 level of nesting
end
```
### Default Arguments
Lua has no native defaults. Use the `or` idiom for simple cases, explicit `nil` checks for values where `false` or `0` are valid:
```lua
-- Simple default (WRONG if false/0 are valid values)
local function greet(name, greeting)
name = name or "Adventurer"
greeting = greeting or "Hello"
return format("%s, %s!", greeting, name)
end
-- Precise default (handles false/0 correctly)
local function setEnabled(flag)
if flag == nil then flag = true end -- Default to true; false is a valid input
end
```
### Varargs
Varargs behavior differs significantly across Lua versions:
| Version | Access pattern | Length |
|---------|---------------|--------|
| 5.0 | `arg` table (auto-created) | `arg.n` |
| 5.1+ | `...` as expression | `select("#", ...)` |
```lua
-- Lua 5.1+: Use select for length (handles trailing nil)
local function logAll(...)
local n = select("#", ...)
for i = 1, n do
local v = select(i, ...)
print(format("arg[%d] = %s", i, tostring(v)))
end
end
-- Lua 5.0: Use the implicit 'arg' table
local function logAll(...) -- 5.0 creates 'arg' automatically
for i = 1, arg.n do
print(format("arg[%d] = %s", i, tostring(arg[i])))
end
end
-- UNIVERSAL: Works on 5.05.4
local function logAll(...)
local args = select and { n = select("#", ...), ... } or arg
for i = 1, args.n do
print(format("arg[%d] = %s", i, tostring(args[i])))
end
end
```
## Strings
- **Short concatenation**: `..` is fine for 23 pieces.
- **Loop-built strings**: Use `table.concat` to avoid O(n²) intermediate string allocations.
- **Structured output**: Prefer `string.format` over `..` chains for readability and localization.
```lua
-- BAD: O(n²) string growth in a loop
local result = ""
for _, v in ipairs(data) do
result = result .. tostring(v) .. ", "
end
-- GOOD: O(n) via table.concat
local parts = {}
for i, v in ipairs(data) do
parts[i] = tostring(v)
end
local result = tconcat(parts, ", ")
```
## Error Handling
Lua uses `pcall` / `xpcall` as its try/catch equivalent. Use them at system boundaries (event handlers, plugin entry points, data loading). NEVER silently swallow errors.
```lua
-- pcall: Returns ok, result_or_error
local ok, err = pcall(function()
dangerousOperation()
end)
if not ok then
Questie:Debug(Questie.DEBUG_CRITICAL,
"[Module] Operation failed: " .. tostring(err))
end
-- xpcall: Adds a message handler for stack traces
local function errorHandler(msg)
return tostring(msg) .. "\n" .. debugstack(2)
end
local ok, result = xpcall(dangerousOperation, errorHandler)
```
**Version note**: In Lua 5.0 and 5.1, `xpcall` does NOT support passing arguments to the called function. Wrap in a closure:
```lua
-- WRONG in 5.0/5.1: xpcall does not forward args
local ok, result = xpcall(dangerousOp, errorHandler, arg1, arg2)
-- CORRECT (universal): Wrap in closure
local ok, result = xpcall(function()
return dangerousOp(arg1, arg2)
end, errorHandler)
```
### Error Propagation
Lua uses the `success, result` multi-return pattern (no exceptions). Propagate errors by returning `nil, errorMessage`:
```lua
local function loadConfig(path)
local f, err = io.open(path, "r")
if not f then return nil, "Cannot open: " .. tostring(err) end
local content = f:read("*a")
f:close()
if not content or content == "" then
return nil, "Empty config file: " .. path
end
return content
end
-- Caller
local config, err = loadConfig("settings.ini")
if not config then
error("Config load failed: " .. err)
end
```
## Nil Guards and Defensive Access
Always check `nil` before indexing. Chain guards for deep access:
```lua
-- Safe deep access (short-circuit on nil)
local name = myData and myData.info and myData.info.name
-- Explicit nil check when 0, false, or "" are valid values
if myData.count ~= nil then
processCount(myData.count) -- count could be 0
end
```
## Metatables
Use metatables for `__index` delegation, operator overloads, `__tostring`, and read-only enforcement. **Never** expose raw metatables of security-sensitive tables publicly; use `__metatable` to guard them.
```lua
-- Guard metatable from external manipulation
local Secret = {}
Secret.__index = Secret
Secret.__metatable = "Access denied" -- getmetatable() returns this string
function Secret:New(value)
return setmetatable({ _value = value }, Secret)
end
```
### Common Metamethods
| Metamethod | Purpose | Example |
|-----------|---------|---------|
| `__index` | Delegation / inheritance | Class systems, proxy tables |
| `__newindex` | Intercept writes | Read-only guards, validation |
| `__tostring` | Custom `tostring()` output | Debug printing |
| `__call` | Make a table callable | Functor pattern |
| `__len` | Custom `#` operator | Tables: 5.2+ only; userdata: 5.1+; ignored for tables in 5.0/5.1 |
| `__eq`, `__lt`, `__le` | Comparison operators | 5.0+; **5.4**: `__le` must be explicit (no longer derived from `__lt`) |
| `__gc` | Garbage collection finalizer | Tables: 5.2+ only; userdata: 5.0+ |
| `__close` | To-be-closed variable cleanup | 5.4+ only; `local x <close> = resource` |
| `__metatable` | Protect from `getmetatable` | Security-sensitive objects (5.0+) |
| `__concat` | Custom `..` operator | String-like objects (5.0+) |
## Coroutines
Use coroutines for cooperative multitasking — chunked database compilation, lazy iteration over large datasets, and frame-budgeted work in game engines.
**Critical Lua 5.0/5.1 limitation**: You cannot `yield` from inside a `pcall`/`xpcall` call. This throws `"cannot resume dead coroutine"`. Structure your code so the yield happens outside the protected call. This was fixed in Lua 5.2+ (yield-across-pcall support).
**Lua 5.0 note**: The full `coroutine` library (`create`, `resume`, `yield`, `status`, `wrap`) is available in Lua 5.0. However, in 5.0, you cannot yield from inside a C function, a metamethod, or an iterator — only from the main coroutine body. This restriction was relaxed in 5.1 (yield from iterators became possible) and further in 5.2 (yield across `pcall`/`xpcall`).
```lua
local fmod = math.fmod or math.mod -- 5.0 has math.mod, 5.1+ has math.fmod
local co = coroutine.create(function()
for i = 1, 10000 do
processRecord(i)
if fmod(i, 100) == 0 then -- 5.0: no % operator; use fmod shim
coroutine.yield() -- Pause every 100 records
end
end
end)
-- Resume from a frame ticker (WoW: C_Timer.After, OnUpdate)
local function tick()
if coroutine.status(co) ~= "dead" then
local ok, err = coroutine.resume(co)
if not ok then
error("Coroutine error: " .. tostring(err))
end
end
end
```
## Numeric Precision
Lua 5.05.2 use 64-bit IEEE doubles for **all** numbers (no integer subtype). Lua 5.3+ introduced a separate integer subtype. Integers above 2^53 lose precision in double-only versions. Be aware of floating-point edge cases:
```lua
-- WRONG: Float comparison
if result == 0.3 then -- May fail due to IEEE 754
-- CORRECT: Epsilon comparison
local EPSILON = 1e-9
if math.abs(result - 0.3) < EPSILON then
```
## Debug Output
- **No `print()` in production code** — it bypasses logging levels, cannot be filtered, and causes taint in WoW.
- Gate debug output behind a severity level flag.
- Use structured debug helpers.
```lua
-- BAD
print("Loading quest " .. questId)
-- GOOD
Questie:Debug(Questie.DEBUG_DEVELOP,
format("[QuestieDB] Loading quest %d", questId))
```
## File Organization
MANY SMALL FILES > FEW LARGE FILES:
- 200400 lines typical, 800 lines absolute maximum
- One module / class per file
- Group by feature/domain, not by type
- Data files (large lookup tables) may exceed 800 lines — exclude them from line-count rules
```text
Database/
├── QuestieDB.lua # Core query interface
├── compiler.lua # Binary compilation
├── Corrections/
│ ├── QuestCorrections.lua
│ └── NPCCorrections.lua
├── Data/
│ ├── questData.lua # Raw data (exempt from 800-line rule)
│ └── npcData.lua
└── Zones/
└── zoneDB.lua
```
## Code Quality Checklist
Before marking work complete on any Lua file:
- [ ] All variables are `local` (zero undeclared globals)
- [ ] Functions are under 50 lines
- [ ] Files are under 800 lines (data files exempt)
- [ ] No nesting deeper than 4 levels
- [ ] Error paths return `nil, errMsg` or log explicitly — never silently swallowed
- [ ] No hardcoded magic numbers (use named constants)
- [ ] Table iteration uses the correct primitive (`ipairs`, `pairs`, `next`, numeric `for`)
- [ ] String building in loops uses `table.concat`, not `..`
- [ ] `luacheck` passes with zero warnings
- [ ] `stylua --check` passes