Linting
All fifty one lints, their default levels, and the flag comments that silence them
updated Aug 19, 202616 min read
larvae lint reports suspicious code. It ships 51 lints, configured in the [lint] table of larvae.toml. The table is optional: larvae lint works with no config at all.
larvae covers all 28 lints of the Luau compiler, so larvae init turns Luau's own linter off in .luaurc and the project loses no report. With both linters on, each finding would arrive twice under two names, each needing its own comment to silence. Quick start describes that step.
Levels
Every lint has a level: allow (off), warn (reported), or deny (fails the run). [lint.rules] sets levels by name:
[lint.rules]
shadowing = "allow"
unused_variable = "deny"Five lints do not default to warn. undefined_variable denies, because the name is nil at runtime and the line that reads it throws. high_cyclomatic_complexity, multiple_statements, non_const_require, and prefer_const allow, and none is a lint of the Luau compiler: a branch count is not a defect on its own, a second statement on a line is a style choice, and const is larvae's own reading of Luau. Each Luau lint keeps the level that Luau gives it, so the change of linter changes no report.
The 51 lints
Each name links to its section below, so an editor or an extension can deep link to one lint.
| Lint | Default | What it reports |
|---|---|---|
almost_swapped |
warn | Two assignments that look like a swap but overwrite one of the values. |
bad_comment_directive |
warn | A --! directive that Luau does not know, or one that comes after the code it should govern. |
bad_string_escape |
warn | An escape sequence the language does not define. |
builtin_global_write |
warn | An assignment over a standard global, which every later script sees. |
compare_nan |
warn | Comparing against nan, which is never equal to anything including itself. |
comparison_precedence |
warn | not a == b, or a chain like a < b < c, which does not group the way it reads. |
constant_table_comparison |
warn | Comparing against a table literal, which compares identity and is always false. |
deprecated |
warn | A function that still works but has been replaced. |
divide_by_zero |
warn | Dividing by a literal zero. |
duplicate_function |
warn | Two functions of the same name in one scope, where the first is discarded. |
duplicate_keys |
warn | A table key written twice, where only the last one survives. |
duplicate_local |
warn | One local statement or parameter list that declares the same name twice. |
empty_if |
warn | An if branch with nothing in it. |
empty_loop |
warn | A loop body with nothing in it. |
format_string |
warn | A format string that string.format or os.date rejects at runtime. |
global_usage |
warn | Reaching into _G, which is shared with every other script. |
high_cyclomatic_complexity |
allow | A function with more branches than anyone can hold at once. |
if_same_then_else |
warn | Two branches of the same if with identical bodies. |
ifs_same_cond |
warn | An elseif repeating a condition already tested, which can never run. |
implicit_return |
warn | A function that returns a value on one path and falls off the end on another. |
loop_invariant_call |
warn | A call inside a loop whose result cannot change between iterations. |
manual_table_clone |
warn | A loop that copies a table, which table.clone does in one call. |
misleading_and_or |
warn | cond and false or b, which always gives b because the middle is never truthy. |
mismatched_arg_count |
warn | Calling a function in this file with the wrong number of arguments. |
mixed_table |
warn | A table with both array entries and named keys. |
multiple_statements |
allow | More than one statement on a line. |
must_use |
warn | Calling a pure function and discarding what it returned. |
non_const_require |
allow | A required module bound with local, where const says it never changes. |
number_literal_overflow |
warn | A hexadecimal or binary literal wider than 64 bits, which is truncated. |
parenthese_conditions |
warn | Parentheses around a condition, which Luau does not need. |
placeholder_read |
warn | Reading _, the name that says a value is discarded. |
prefer_const |
allow | A local that nothing reassigns, which const states outright. |
restricted_module_paths |
warn | Requiring a module the project has ruled out. Quiet until the config names a path. |
roblox_incorrect_color3_new_bounds |
warn | Color3.new given a channel over 1, where the scale is 0 to 1. |
roblox_manual_fromscale_or_fromoffset |
warn | A UDim2.new that fromScale or fromOffset says more clearly. |
roblox_suspicious_udim2_new |
warn | UDim2.new given two arguments, where it takes four. |
self_assignment |
warn | Assigning a value to itself, which does nothing. |
shadowing |
warn | A name that hides another still in scope. |
string_concat_in_loop |
warn | Building a string by concatenation in a loop, which is quadratic. |
suspicious_reverse_loop |
warn | A numeric for counting down without a negative step, which never runs. |
table_operations |
warn | A table.insert or table.remove whose index or argument count is wrong. |
type_check_inside_call |
warn | A comparison inside type(), where it belongs outside. |
unbalanced_assignments |
warn | More names than values, or more values than names. |
undefined_variable |
deny | A name nothing declares, which is nil at runtime. |
uninitialized_local |
warn | A local declared with no value and never assigned, so every read is nil. |
unknown_type |
warn | Comparing type(x) against a string that type() never returns. |
unreachable_code |
warn | Statements after a return, break or continue, which never run. |
unscoped_variables |
warn | An assignment with no local, which creates a global. |
unused_function |
warn | A local function that nothing calls, and a global function f() end that no line reads. |
unused_variable |
warn | A name declared and never read. A local function is unused_function instead. |
zero_step_loop |
warn | A numeric for whose step is zero, so the counter never moves. |
larvae lint --explain <name> prints one lint's description. The schema contains all lints with their defaults, so an editor can offer them by name. larvae init writes only std = "roblox" and a pointer to [lint.rules], not the lint list.
almost_swapped
Two adjacent assignments read like a swap, but the first assignment overwrites one of the values before the second one reads it. Both names end up holding the same value.
local a, b = 1, 2
a = b
b = a -- a was overwritten first, so both hold 2
-- fine: a, b = b, abad_comment_directive
A --! comment is a directive to Luau. This lint reports a directive Luau does not know, and a directive placed after code, because a late directive governs nothing.
--!non-strict -- Luau does not know this name; it spells it nonstrict
local x = 1
--!strict -- comes after the code it should govern, so it does nothingbad_string_escape
The string holds a backslash escape the language does not define, so the string does not mean what the backslash suggests.
local dir = "C:\Users\me" -- \U and \m are not escape sequences
-- fine: local dir = "C:\\Users\\me"builtin_global_write
The assignment replaces a standard global. Globals are shared, so every later script sees the replacement instead of the builtin.
print = function() end -- every later print call now does nothingcompare_nan
nan is never equal to anything, itself included, so == nan is always false and ~= nan is always true.
local nan = 0 / 0
local function isNan(x: number)
return x == nan -- always false, nan is not equal even to itself
end
-- fine: return x ~= xcomparison_precedence
not a == b groups as (not a) == b, and a chain like a < b < c compares a boolean against c. Neither means what the line reads as.
local function different(a, b)
return not a == b -- groups as (not a) == b
end
-- fine: return a ~= bconstant_table_comparison
== on tables compares identity, and a table literal is a fresh table with a new identity. The comparison is always false.
local function isEmpty(t)
return t == {} -- a new table is never the same table, always false
end
-- fine: return next(t) == nildeprecated
The called function still works but has been replaced, and the replacement is the supported spelling.
wait(1) -- wait still works, but task.wait replaced it
-- fine: task.wait(1)deprecated options
| Option | Default | What it does |
|---|---|---|
additional |
none | Names the project has deprecated of its own, as old = "what to use instead". |
[lint.options.deprecated]
additional = { getData = "use fetchData" }divide_by_zero
Dividing by a literal zero never throws in Luau: it gives inf, and 0 / 0 gives nan. Neither is usually the value the author wanted.
local x = 1 / 0 -- always inf
local y = 0 / 0 -- always nanduplicate_function
Two functions of the same name in one scope: the second definition replaces the first, so the first body is discarded.
local M = {}
function M.reset() end
function M.reset() end -- the first reset is discardedduplicate_keys
A table constructor writes the same key twice. Only the last write survives, and the earlier value is discarded silently.
local color = { r = 1, g = 1, r = 0 } -- r is written twice, only the last survivesduplicate_local
One local statement or one parameter list declares the same name twice. The second declaration hides the first inside the same statement.
local width, width = 10, 20 -- one statement declares width twiceempty_if
An if branch with nothing in it. Either the body was forgotten, or the branch can go.
local ready = false
if ready then
end -- nothing happens either wayempty_loop
A loop body with nothing in it. The loop spins without an effect.
for i = 1, 10 do
end -- nothing in the bodyformat_string
The format string is one that string.format or os.date rejects at runtime, so the call throws when it runs.
local s = string.format("%d of %w", 3, 7) -- %w is not a specifierglobal_usage
The code reaches into _G, which every other script shares. A write here is visible everywhere, and a read here depends on what every other script did.
_G.playerCount = 12 -- every script shares _Ghigh_cyclomatic_complexity
Default level: allow, because a branch count is not a defect on its own. It is not a lint of the Luau compiler.
The function has more branches than the configured maximum. The example lowers the maximum to 3 so a short function shows the report:
-- with maximum_complexity = 3:
local function grade(n)
if n > 90 then return "A" elseif n > 80 then return "B"
elseif n > 70 then return "C" else return "D" end
endhigh_cyclomatic_complexity options
| Option | Default | What it does |
|---|---|---|
maximum_complexity |
40 |
Branches a function may have before it is reported. selene's default, kept so a project moving over gets the same answers. |
if_same_then_else
Two branches of the same if have identical bodies, so the condition decides nothing.
local function pick(fast: boolean)
if fast then return 1 else return 1 end -- both branches are identical
endifs_same_cond
An elseif repeats a condition the chain already tested. The earlier branch took every case, so this one can never run.
local function describe(n)
if n == 0 then return "zero"
elseif n == 0 then return "none" -- same condition, never runs
else return "some" end
endimplicit_return
The function returns a value on one path and falls off the end on another. The caller gets a value sometimes and nil the rest of the time.
local function find(t, v)
for i, x in t do
if x == v then return i end
end
end -- falls off the end and returns nothingloop_invariant_call
A call inside a loop whose arguments do not change between iterations. The result is the same every pass, so the call belongs above the loop.
local angles = {}
for i = 1, 360 do
angles[i] = math.rad(90) -- the argument never changes, hoist the call out
endmanual_table_clone
The loop copies every pair of a table into a fresh one, which table.clone does in one call.
local original = { a = 1 }
local copy = {}
for k, v in original do
copy[k] = v
end
-- fine: local copy = table.clone(original)misleading_and_or
In cond and x or b, when x is false or nil the middle is never truthy, so the expression always gives b whatever cond is.
local function pickMode(fast: boolean)
return fast and nil or "slow" -- always "slow", because nil is never truthy
end
-- fine: return if fast then nil else "slow"mismatched_arg_count
A call to a function defined in this file passes the wrong number of arguments for its parameter list.
local function add(a, b)
return a + b
end
add(1, 2, 3) -- add takes two argumentsmixed_table
The table holds both array entries and named keys. Iteration order and length are easy to get wrong on such a table.
local cfg = { "fast", retries = 3 } -- an array entry and a named key in one tablemultiple_statements
Default level: allow, because a second statement on a line is a style choice rather than a defect. It is not a lint of the Luau compiler.
More than one statement shares a line, and the second one is easy to miss.
local x = 1 print(x) -- two statements share this line
-- fine: put each statement on its own linemust_use
The called function is pure: it computes a value and changes nothing else. Discarding the result makes the call do nothing.
local name = "larvae"
string.upper(name) -- the returned string is discarded, nothing changes in place
-- fine: name = string.upper(name)non_const_require
Default level: allow, because const is newer than most codebases. It is not a lint of the Luau compiler.
A required module is bound with local, where const states that the binding never changes. The lint skips a name that the file reassigns, because const would then be a syntax error.
local Signal = require("@game/ReplicatedStorage/packages/signal")
-- fine: const Signal = require("@game/ReplicatedStorage/packages/signal")number_literal_overflow
A hexadecimal or binary literal wider than 64 bits does not fit, so the value is truncated silently.
local mask = 0xFFFFFFFFFFFFFFFFF -- 17 hex digits, wider than 64 bits, truncatedparenthese_conditions
Parentheses around a whole condition, which Luau does not need. They are a habit from other languages.
local ready = true
if (ready) then -- Luau does not need the parentheses
print("go")
endplaceholder_read
The name _ says a value is discarded. Reading it contradicts that promise, so either the read or the name is wrong.
local scores = { 10, 20 }
local _, first = next(scores)
print(_) -- _ says the value is discarded, and here it is readprefer_const
Default level: allow, because const is larvae's own reading of Luau, so a codebase of ordinary local would report on nearly every line the first time it ran. It is not a lint of the Luau compiler.
A local that nothing reassigns, which const states outright.
local retries = 3 -- nothing reassigns retries
print(retries)
-- fine: const retries = 3Three forms are left alone, and each one is a place where const does not compile or does not exist. A declaration with no initialiser stays, because const x is "Missing initializer in const declaration". A local function and a for variable take no const at all. And const binds the declaration and not one name inside it, so local a, b = 1, 2 is reported only when nothing reassigns either name; where one of them changes, there is no edit to suggest.
prefer_const options
| Option | Default | What it does |
|---|---|---|
mutated_tables_stay_local |
false |
Keep local on a binding the file mutates through a field, such as t.x = 1 or table.insert(t, 1). |
The option is off because const is correct on a mutated table. Luau enforces const against reassignment of the name and says nothing about the value, so this compiles:
const t = {}
t.x = 1
table.insert(t, 2)The option is for a project that reads local as "this one changes" and wants the two keywords to carry that difference. With it on, larvae skips a binding the file changes through a field, an index, a nested chain, a compound assignment, or one of the table functions that mutates its first argument. table.freeze is not one of them, because it returns a copy, so a binding that only reaches table.freeze still reports.
[lint.options.prefer_const]
mutated_tables_stay_local = truerestricted_module_paths
The require names a module path the project has ruled out in its config, and the report carries the configured reason. The lint is quiet until [lint.options.restricted_module_paths] names something.
local util = require("@game/ReplicatedStorage/legacy/util")
-- reported when the config forbids this pathrestricted_module_paths options
| Option | Default | What it does |
|---|---|---|
paths |
none | Require paths the project forbids, as path = "why". |
[lint.options.restricted_module_paths]
paths = { "@game/ReplicatedStorage/legacy/util" = "use packages/util instead" }roblox_incorrect_color3_new_bounds
Color3.new takes channels on a 0 to 1 scale. A value over 1 is almost always an RGB value on the 0 to 255 scale, which Color3.fromRGB takes.
local red = Color3.new(255, 0, 0) -- channels run 0 to 1, not 0 to 255
-- fine: local red = Color3.fromRGB(255, 0, 0)roblox_manual_fromscale_or_fromoffset
A UDim2.new whose offsets are all zero, or whose scales are all zero, has a constructor that says the same thing more clearly.
local size = UDim2.new(0.5, 0, 1, 0)
-- fine: local size = UDim2.fromScale(0.5, 1)roblox_suspicious_udim2_new
UDim2.new takes four arguments. Given two, they are read as the x scale and the x offset, and the y axis is zero, which is rarely the intent.
local frame = Instance.new("Frame")
frame.Size = UDim2.new(0.5, 0.5) -- read as x scale and x offset, y stays zero
-- fine: frame.Size = UDim2.fromScale(0.5, 0.5)self_assignment
A value is assigned to itself, which does nothing. Usually one of the two sides was meant to be another name.
local health = 100
health = health -- does nothingshadowing
A declaration reuses a name that is still in scope, so the rest of the block cannot reach the outer value.
local count = 0
local function step(count) -- hides the outer count
return count + 1
endstring_concat_in_loop
Each .. in the loop copies the whole string built so far, so the loop is quadratic in the total length. Collecting parts and joining once is linear.
local lines = { "a", "b" }
local out = ""
for _, line in lines do
out = out .. line -- copies the whole string each pass
end
-- fine: local out = table.concat(lines)suspicious_reverse_loop
A numeric for counting from a larger value down to a smaller one, without a step. The default step is 1, so the loop body never runs.
local items = { "a", "b" }
for i = #items, 1 do -- the default step is 1, so this never runs
end
-- fine: for i = #items, 1, -1 dotable_operations
A table.insert or table.remove whose index or argument count is wrong for what the function does.
local list = { "b", "c" }
table.insert(list, 0, "a") -- index 0 is before the first elementtype_check_inside_call
The comparison sits inside the type() call, so type receives a boolean and the result is always the string "boolean", which is truthy.
local function isNumber(x)
return type(x == "number") -- the comparison belongs outside the call
end
-- fine: return type(x) == "number"unbalanced_assignments
The assignment lists more names than values, or more values than names. The extra names are nil, and the extra values are discarded.
local x, y, z = 1, 2 -- three names, two values, z is nilundefined_variable
Default level: deny, because the name is nil at runtime and the line that reads it throws.
Nothing declares the name, so reading it gives nil.
local total = subtotal + 0.2 -- nothing declares subtotal, so this line throwsuninitialized_local
The local is declared with no value and nothing ever assigns one, so every read gives nil.
local count
print(count) -- count is never assigned, so this prints nilunknown_type
The code compares type(x) against a string that type() never returns, so the comparison is always false.
local function isInt(x)
return type(x) == "integer" -- type() never returns "integer"
end
-- fine: return type(x) == "number"unreachable_code
Statements placed after the point where every path has already returned, broken, or continued. They can never run.
local function report(ok)
if ok then return "ok" else return "bad" end
print("done") -- both branches return, so this never runs
endunscoped_variables
An assignment with no local creates a global, visible to every script, usually by accident.
local function setup()
counter = 0 -- no local, so counter becomes a global
end
-- fine: local counter = 0A function f() end declaration is not reported. It creates a global the same way, but neither selene nor the Luau compiler calls that an unscoped variable.
unused_function
A local function that nothing calls, and a global function f() end that no line reads.
local function helper() -- nothing calls helper
return 1
endThe declaring form decides which unused lint fires, not the value: local function f() end is unused_function, and local f = function() end is unused_variable, though both hold a function. Each carries its own level, so a project that keeps unused helpers around while still wanting unused locals reported can say so. Both lints read [lint.options.unused_variable], because ignore_pattern means the same thing to either one, and _helper silences a function as it silences a variable.
unused_variable
The name is declared and never read, so the declaration does nothing for the program. The declaring form decides, not the value: a local function that nothing calls is unused_function instead, and the two lints carry their own levels while sharing the options below.
local retries = 3 -- declared and never read
local _limit = 5 -- fine: the name matches ignore_pattern "^_"unused_variable options
| Option | Default | What it does |
|---|---|---|
parameters |
false |
Report unused function parameters too. Off by default, because a parameter is part of a signature the caller decides. |
loop_variables |
false |
Report unused for variables too. Off by default, because for k, v where only k is wanted is how the language is written. |
ignore_pattern |
"^_" |
Names exempted, as a regular expression. |
zero_step_loop
A numeric for whose step is zero. The counter never moves, so the loop never ends or never runs as intended.
for i = 1, 10, 0 do -- the step is zero, so i never moves
endThe [lint] keys
| Key | What it does |
|---|---|
std |
The global environment lints check against. Default "roblox". |
globals |
Extra globals the project defines. |
exclude |
Globs the linter skips, relative to the project root. |
include |
Globs the linter reads back, over every exclude. |
[lint.rules] |
Levels by lint name. |
[lint.options] |
Options for unused_variable, high_cyclomatic_complexity, deprecated, restricted_module_paths, and prefer_const. Also spelled [lint.config], selene's name. |
std accepts selene's spellings: lua51 through lua54, luajit, and chains such as roblox+testez, where the first name decides. A selene.toml on disk is read the same way, and for lists (rules, globals, exclude) larvae adds its entries to selene's and does not replace them. The exclusion order is on configuration.
Flag comments
A flag comment is a comment addressed to larvae, not to a reader:
local unused = 1 -- larvae: allow(unused_variable)
-- larvae: allow(unused_variable, shadowing)
local a, b = 1, 2
-- selene: allow(unused_variable) -- selene's spelling works too
-- larvae: allow(*) -- everything on this lineA flag covers its own line and the line below it. People write both forms, and to guess which form an author meant is worse than to accept both.
Switching the linter off over a span
A second family holds a tool off over a span:
-- larvae: lint off
local DEBUG = true
local VERBOSE = false
-- larvae: lint onoff runs to the matching on, or to the end of the file when no on follows. One marker therefore covers three needs:
| Written | Holds |
|---|---|
-- larvae: lint off at the top, no on |
the whole file |
-- larvae: lint off then -- larvae: lint on |
the lines between, markers included |
-- larvae: lint off(5) |
the marker line and five lines below it |
A lint marker holds every lint, where allow(...) names the lints it holds. Each subject reads its own markers only, so a fmt off does not quiet the linter. The fmt half of the family lives on formatting.
A marker larvae cannot read is an ordinary comment. -- larvae: lint off(five) names no count, so it is not a flag: it stays in the file where a reader sees it, and the linter runs. That is deliberate, because were it a flag, larvae process would strip it from the build and the linter would hold off to the end of the file, with nothing to say why.
These markers are flags, so larvae process strips them by default, as it strips allow(...).
Only allow(...) and this family are flags. The comment -- larvae: this one is load bearing is a note to the next reader, and larvae treats it as an ordinary comment.
larvae process removes flags by default, because flags are build time instructions and shipped flags are shipped build instructions. Ordinary comments do not change and line numbers stay the same, so retain-lines output still matches the source. Set [process] strip_flags = false when people read the output, for example a library published as source. When [rules] remove_comments is on, that rule controls every comment in the file and strip_flags does nothing, so a project that keeps flags with an except pattern keeps them.
Lints that come from a worm
A worm adds lints under its own key: [lint.rules.<worm>] is a table of levels, and each name reads <worm>.<name> in a message, in --explain, and in an allow comment.
[lint.rules.markup]
tidy = "deny"See using worms.
Lint or check
[lint] holds per-file questions only: a lint reads one file in isolation, which is why larvae lint works on stdin, in the editor, and with no config. A question about the whole project, ex: a require cycle, lives in [check] and runs under larvae check. The split follows the tools it mirrors: selene lints, cargo check checks. The two tables share one level vocabulary. See configuration.