Files
Questie-X/.agent/rules/lua-coding-style.md
T

617 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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