lua-doctor
v1.0.0
Published
Your agent writes bad Lua. This catches it. Deterministic static analysis for Lua codebases across correctness, reliability, performance, security and maintainability — with a first-class Canary/TFS (OpenTibia) profile.
Maintainers
Readme
lua-doctor
Your agent writes bad Lua. This catches it.
Deterministic static analysis for Lua codebases — 58 rules across correctness, reliability, performance, security and maintainability, with a first-class Canary/TFS (OpenTibia) profile generated straight from the engine's C++ source.
npx lua-doctor@latest x x
⌒
11/100 Critical
███░░░░░░░░░░░░░░░░░░░░░░░░░░░
Correctness ██████░░░░░░░░░░░░░░ 29
Reliability ███████████████░░░░░ 74
Performance ███████████████████░ 95
Security ██████████████████░░ 91
Maintainability █████████░░░░░░░░░░░ 45What it finds
Things that crash or silently break a live server, not style nits:
addEventuserdata captures — the classic OT use-after-free crash, including closures that smuggle aPlayeras an upvalue past the engine guard- Typos that become
nilat runtime — whole-program resolution of every global read/method call against your codebase plus the engine API parsed fromsrc/lua/functions/**/*.cpp(1.8k globals, 1.2k methods, every enum incl.magic_enumloops) - Revscript events that never fire — missing
:register(),CreatureEvents never registered to any creature - SQL injection —
db.query("..." .. param)with player-controlled talkaction input, plusos.execute/loadstringvectors - Game-thread stalls — synchronous
db.queryin loops, quadratic string concat, allocations insideonThink - Shared-environment corruption — globals leaking from scripts, duplicate global functions where load order decides which one survives, item ids that don't exist in
items.xml
Run npx lua-doctor rules for the full catalog or see RULES.md — every rule documents why it exists with sources (luacheck, selene, Luau lints, lua.org performance notes, OTLand engine threads).
Usage
npx lua-doctor@latest # interactive scan of the cwd
npx lua-doctor /path/to/server # scan a specific root
npx lua-doctor --project data,data-molten --ci # non-interactive
npx lua-doctor --json # machine-readable report
npx lua-doctor --sarif out.sarif # GitHub code scanning format
npx lua-doctor --baseline # only fail on issues new vs merge-base
npx lua-doctor rules # list all rules
npx lua-doctor explain addevent-closure-capture
npx lua-doctor install # add the GitHub Actions workflowGitHub Actions
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: lucaseatp/lua-doctor@v1
with:
fail-on: error # PRs fail only on *introduced* errorsOn pull requests the action scans the merge-base in a temporary worktree, fingerprints findings (line-shift resistant), and posts a sticky comment with the score, dimension breakdown and the new issues — so a legacy codebase with 5k findings can adopt it today and only ever block regressions.
Outputs: score, errors, warnings, infos, total, introduced, fixed.
Configuration
lua-doctor.config.jsonc at the scan root (all fields optional):
{
"profile": "auto", // auto | canary | vanilla
"include": [], // globs; empty = everything
"exclude": ["**/vendor/**"],
"rules": {
"magic-storage-key": "off", // error | warning | info | off
},
"overrides": [
{ "files": ["**/migrations/**"], "rules": { "prefer-async-db-write": "off" } }
],
"globals": ["MyCustomGlobal"], // extra engine globals
"thresholds": { "cyclomaticComplexity": 15, "functionLines": 150, "nestingDepth": 5 },
"failOn": "error" // error | warning | none
}Inline suppressions:
-- lua-doctor-disable-next-line sql-string-concat
db.query("SELECT ..." .. id)
-- lua-doctor-disable-file magic-storage-keyScoring
The score is computed locally and deterministically — no API, no telemetry, same input ⇒ same score, works offline and in CI forever:
density = (errors×10 + warnings×3 + infos×2) per 1000 lines
dimension = round(100 × e^(−density/20) × e^(−√errors/10))
overall = round(0.6 × weighted mean + 0.4 × worst dimension)Two penalties multiply per dimension: a density factor (how dirty the code is for its size) and an absolute error factor that is not size-normalized — 1 error caps the dimension at ~90, 25 errors ~60, 100 errors ~37, 900 errors ~5. A big repo can't dilute real errors away.
The weighted mean uses dimension importance (correctness 30%, reliability 25%, security 20%, maintainability 15%, performance 10%), so the overall score always sits between your worst dimension and the average — one bad dimension drags it down without collapsing it.
Canary/TFS profile
std/canary.json is generated by parsing the engine source — registerClass/registerMethod/registerGlobalMethod/lua_register/registerEnum/registerEnumNamespace and magic_enum registration loops — plus every item id in data/items/items.xml. Regenerate after engine changes:
pnpm generate-std /path/to/canaryThe profile also encodes engine semantics that can't be derived mechanically: which classes are pushed as tables (Position — safe for addEvent) vs userdata (everything else), boolean-contract callbacks, lookup constructors that return nil, and the curated TFS 0.x compat-shim list (LuaJIT-aware: unpack/table.maxn are not flagged).
Performance
3,714 files / 356k lines of the reference server scan in ~0.5s (worker threads) — fast enough for a pre-commit hook.
License
MIT
