Linting
All sixty three lints, their default levels, and the flag comments that silence them
updated Aug 30, 202627 min read
larvae lint reports suspicious code. It ships 64 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), info, warn, or deny (fails the run). [lint.rules] sets levels by name:
[lint.rules]
shadowing = "allow"
mixed_table = "info"
unused_variable = "deny"info and warn both report, and both leave the exit code alone. They differ in what they ask of the reader: an editor draws an info as a hint and a warning as a squiggle. info is where a lint goes when a project wants it on the record without adding to the pile it reads every day. The summary line counts infos apart from warnings, and only when a project uses the level, so a project that never writes info reads the summary it always read.
Seventeen of the 64 lints do not warn by default: six deny and eleven allow.
A lint denies when two things hold at once. No reading of the code makes the finding wrong, because the check reads a literal or the syntax and never a runtime value. And the code as written cannot be what the author meant: it throws, it hangs, or a line of it is dead. Six pass that bar. undefined_variable, because the name is nil at runtime and the line that reads it throws. duplicate_keys, because { a = 1, a = 2 } discards the first entry. duplicate_local, because local a, a kills the first binding where it stands. format_string, because string.format("%y", 1) raises. zero_step_loop, because a step of zero never moves the counter. And length_as_condition, because #t yields a number and every number is true in Luau, so if #players then runs with no players. Over a 364 file corpus the five newer denies report nothing at all, which is the point: nobody writes these shapes on purpose, so the gate costs an existing project nothing on the day it adopts larvae.
bad_string_escape is the near miss worth naming. It reads a literal and it cannot be wrong, but Luau accepts "\q" and runs the file. The string is wrong and the build is fine, so it warns.
Eleven lints allow. high_cyclomatic_complexity and non_const_require, because a branch count is not a defect on its own and const is newer than most codebases. prefer_const, because it rewrites a keyword. implicit_return, because a lookup that returns a value on one path and falls off the end on another is idiomatic Luau. multiple_statements, because larvae fmt owns that ground already: with collapse_simple_statement at never, a format run removes every finding the lint can make. implicit_any_parameter, because most Luau carries no annotations and a warn default would report hundreds of times on the first run. And five lints about the shape of a branch or a value: and_or_conditional, if_expression_assignment, else_after_return, collapsible_if, and negated_condition. Each states a preference about code that is correct.
if_expression_assignment has to stay allow, and the reason is worth the line. misleading_and_or names if cond then a else b as the repair in its own help text, so a default that reported would report the fix larvae asked for.
Every other Luau lint keeps the level that Luau gives it, so the change of linter changes no report.
Groups
Every lint belongs to one of six groups, and [lint.groups] sets a level for all of them at once:
| Group | What it holds |
|---|---|
correctness |
The code cannot do what it says: it throws, it hangs, or a line of it is dead. |
suspicious |
Probably wrong, and a human decides. |
style |
A matter of how the code reads. |
complexity |
More shape than the job needs. |
performance |
Correct, and it costs more than it has to. |
roblox |
A Roblox data type used in a way that does not hold. |
Resolution runs most specific first: a name in [lint.rules], then the group in [lint.groups], then recommended, then the default of the lint. So style = "allow" with mixed_table = "warn" is one style lint back on and the rest off.
Two rules about groups surprise a reader who knows Biome.
The table is separate from [lint.rules], not nested inside it. This is not a matter of taste: a table under [lint.rules] already means the lints of a worm of that name, and nothing reserves a worm name, so a worm published as style would make [lint.rules.style] mean two things at once. A separate table has no such collision, and every config written before groups existed reads exactly as it did. selene.toml needs no change either, because selene has no group concept and its flat [rules] still resolves at the first step.
And a group does not wake a lint that is allow on purpose. prefer_const is off because it rewrites a keyword, and a project that writes style = "info" is asking the style lints it already sees to say less, not asking for a lint it never had. To turn one on, name it in [lint.rules], which beats the group anyway.
larvae lint --explain <name> prints the group of a lint, and the list it prints when a name misses is grouped rather than one alphabetical run.
The 64 lints
Each name links to its section below, so an editor or an extension can deep link to one lint. The tables follow the six groups of [lint.groups], which is also how larvae lint --explain prints the list when a name misses.
correctness
The code cannot do what it says: it throws, it hangs, or a line of it is dead.
| 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. |
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_condition |
warn | A condition that is a literal, so the branch is decided already. |
constant_table_comparison |
warn | Comparing against a table literal, which compares identity and is always false. |
duplicate_function |
warn | Two functions of the same name in one scope, where the first is discarded. |
duplicate_keys |
deny | A table key written twice, where only the last one survives. |
duplicate_local |
deny | One local statement or parameter list that declares the same name twice. |
format_string |
deny | A format string that string.format or os.date rejects at runtime. |
ifs_same_cond |
warn | An elseif repeating a condition already tested, which can never run. |
length_as_condition |
deny | #t used as a condition. A length is a number, and every number is true in Luau. |
misleading_and_or |
warn | cond and x or b where the middle can never be truthy: a literal false or nil, or a boolean that syntax proves. |
mismatched_arg_count |
warn | Calling a function in this file with the wrong number of arguments. |
must_use |
warn | Calling a pure function and discarding what it returned. |
number_literal_overflow |
warn | A hexadecimal or binary literal wider than 64 bits, which is truncated. |
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. |
unused_function |
warn | A local function that nothing calls, and a global function f() end that no line reads. |
unused_import |
warn | A module required and never used. |
unused_variable |
warn | A name declared and never read. A local function is unused_function instead. |
zero_step_loop |
deny | A numeric for whose step is zero, so the counter never moves. |
suspicious
Probably wrong, and a human decides.
| Lint | Default | What it reports |
|---|---|---|
builtin_global_write |
warn | An assignment over a standard global, which every later script sees. |
builtin_shadowed |
warn | A local that takes the name of a standard global, so the library is unreachable below that line. |
divide_by_zero |
warn | Dividing by a literal zero. |
empty_if |
warn | An if branch with nothing in it. |
empty_loop |
warn | A loop body with nothing in it. |
global_usage |
warn | Reaching into _G, which is shared with every other script. |
if_same_then_else |
warn | Two branches of the same if with identical bodies. |
ignored_pcall_result |
warn | A pcall or xpcall used as a statement, which catches the error and discards it. |
implicit_any_local |
warn | A local with no value and no type, so whatever assigns it first decides what it holds. |
implicit_any_parameter |
allow | A parameter with no type, so what the function takes is decided by each caller. |
implicit_return |
allow | A function that returns a value on one path and falls off the end on another. |
mixed_table |
warn | A table with both array entries and named keys. |
placeholder_read |
warn | Reading _, the name that says a value is discarded. |
self_assignment |
warn | Assigning a value to itself, which does nothing. |
shadowing |
warn | A name that hides another still in scope. |
unscoped_variables |
warn | An assignment with no local, which creates a global. |
style
A matter of how the code reads.
| Lint | Default | What it reports |
|---|---|---|
and_or_conditional |
allow | cond and a or b used as a value, where an if statement states one case per branch. |
collapsible_if |
allow | An if that holds one if and nothing else, where the two conditions are one and apart. |
deprecated |
warn | A function that still works but has been replaced. |
else_after_return |
allow | An else after branches that all end in a return, break or continue. |
if_expression_assignment |
allow | if cond then a else b used as a value. |
multiple_statements |
allow | More than one statement on a line. |
negated_condition |
allow | An if with a negated condition and an else, where swapped bodies read in the expected order. |
non_const_require |
allow | A required module bound with local, where const says it never changes. |
parenthese_conditions |
warn | Parentheses around a condition, which Luau does not need. |
prefer_const |
allow | A local that nothing reassigns, which const states outright. |
restricted_globals |
warn | Reading a global the project has ruled out. Silent until the config names one. |
restricted_module_paths |
warn | Requiring a module the project has ruled out. Quiet until the config names a path. |
complexity
More shape than the job needs.
| Lint | Default | What it reports |
|---|---|---|
high_cyclomatic_complexity |
allow | A function with more branches than anyone can hold at once. |
performance
Correct, and it costs more than it has to.
| Lint | Default | What it reports |
|---|---|---|
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. |
string_concat_in_loop |
warn | Building a string by concatenation in a loop, which is quadratic. |
roblox
A Roblox data type used in a way that does not hold.
| Lint | Default | What it reports |
|---|---|---|
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. |
larvae lint --explain <name> prints one lint's description and its group. 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, aand_or_conditional
Default level: allow, because it states a preference about code that is correct.
cond and a or b is used as a value. The decision hides inside an expression, where an if statement states one case per branch.
local fast = true
local speed = fast and 20 or 10 -- the decision hides inside the expressionThe shape also has a trap when the middle can be false or nil, and misleading_and_or reports that case on its own.
bad_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. The check reads a literal, but Luau accepts the escape and runs the file, so the lint warns instead of denying.
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 nothingbuiltin_shadowed
A local takes the name of a standard global, such as local table = {}. The library is unreachable below that line.
local table = {} -- the table library is unreachable below this lineA declaration whose value reads the same global is left alone, because local pairs = pairs caches it and loses nothing.
local pairs = pairs -- fine: caches the global under the same namecollapsible_if
Default level: allow, because it states a preference about code that is correct.
An if holds one if and nothing else. The two conditions are one and apart.
local ready, armed = true, true
if ready then
if armed then -- the outer if holds only this if
print("go")
end
end
-- fine: if ready and armed then print("go") endcompare_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_condition
The condition is a literal, so the branch is decided already. Only nil and false are false in Luau, so if 0 then and if "" then both pass.
if 0 then
print("always") -- only nil and false are false, so 0 passes
endwhile true do and repeat ... until false are exempt, because each one is a deliberate loop.
constant_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
Default level: deny, because { a = 1, a = 2 } discards the first entry, and no reading of the code makes that what the author meant.
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
Default level: deny, because local a, a kills the first binding where it stands.
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 twiceelse_after_return
Default level: allow, because it states a preference about code that is correct.
An else follows a branch that returns, breaks or continues. The jump already ends the branch, so the code in the else runs in the same cases without it. The lint fires only when every branch ends in the jump, because an elseif that falls through still needs the else.
local function sign(n: number)
if n < 0 then
return -1
else -- the branch above returns, so the else can go
return 1
end
endempty_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
Default level: deny, because string.format("%y", 1) raises, and the check reads the literal, so no reading of the code makes the finding wrong.
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_expression_assignment
Default level: allow, and it must stay allow, because misleading_and_or names this exact form as the repair in its own help text. A default that reported would report the fix larvae asked for.
if cond then a else b is used as a value.
local fast = true
local speed = if fast then 20 else 10 -- the if expression as a valueif_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
endignored_pcall_result
A pcall or xpcall is used as a statement. It catches the error and discards it, so the failure leaves no trace at all.
local function save()
error("disk full")
end
pcall(save) -- the error is caught and discarded, no trace remains
-- fine: local ok, err = pcall(save)implicit_any_local
A local is declared with no value and no type, so what it holds is decided by whatever assigns it first. In a file with no --!strict directive Luau then accepts any later assignment of any type. The fix is one of two words: write the type, or write the value.
local items = { 1, 5 }
local found -- no value and no type
for _, v in items do
if v > 3 then
found = v
end
end
-- fine: local found: number?A local that nothing ever assigns is left to uninitialized_local, which says the more urgent thing about the same line.
implicit_any_parameter
Default level: allow, because most Luau carries no annotations, and a warn default would report hundreds of times on the first run.
A parameter has no type, so what the function takes is decided by each caller. The sibling of implicit_any_local, on the other side of the call.
local function scale(n) -- what n is, each caller decides
return n * 2
end
-- fine: local function scale(n: number)implicit_return
Default level: allow, because a lookup that returns a value on one path and falls off the end on another is idiomatic Luau.
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 nothinglength_as_condition
Default level: deny, because #t yields a number, every number is true in Luau, and so the guard does nothing.
#t is used as a condition. A length is a number, and every number is true, so if #players then runs with no players.
local players = {}
if #players then
print("someone is here") -- #players is 0, and 0 is true, so this runs
end
-- fine: if #players > 0 thenloop_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 the middle can never be truthy the expression always gives b whatever cond is. Two cases fire, and each carries its own message.
The middle is a literal false or nil. The expression is wrong for every input.
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"The middle is a boolean that syntax proves. A comparison yields a boolean because it is a comparison, so the check needs no types. Here the expression is wrong for half the inputs: it gives "pending" exactly when the author wanted false.
local function status(ready: boolean, count: number)
return ready and (count == 0) or "pending" -- "pending" when the count is not zero
end
-- fine: return if ready then count == 0 else "pending"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 larvae fmt owns that ground already: with collapse_simple_statement at never, a format run removes every finding the lint can make.
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)negated_condition
Default level: allow, because it states a preference about code that is correct.
An if has a negated condition and an else. Swapping the bodies states the same thing in the order a reader expects.
local ready = false
if not ready then
print("wait")
else
print("go")
end
-- fine: if ready then print("go") else print("wait") endnon_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 = 3In the editor the report carries a code action that changes local to const, so the fix is one click. Editor setup describes the language server.
Three 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_globals
The code reads a global the project has ruled out in its config, and the report carries the configured reason. The lint is quiet until [lint.options.restricted_globals] names one, so it costs nothing until a project asks.
local env = getfenv() -- reported when the config restricts getfenvrestricted_globals options
The table itself is the map: one name = "why" line per global. The reason is the message a reader gets, which is why a bare list of names is not the shape: it would report "this is restricted" and leave them guessing.
[lint.options.restricted_globals]
getfenv = "turns the optimiser off, read the docs page on environments"
setfenv = "turns the optimiser off, read the docs page on environments"getfenv and setfenv are worth naming in a project that cares about speed, because each one turns Luau's optimiser off for the function that holds it.
restricted_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. A local the file assigns later but never types is implicit_any_local instead.
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_import
A module required and bound to a name nothing reads. The fix is always to delete the line, which is why the leftover gets its own lint and its own level instead of riding unused_variable.
local Signal = require("@pkg/signal") -- nothing reads SignalA method that happens to be named require stays a variable question. A worm can also exempt what it knows: in a .luaux file the factory package never reports, because the markup uses it even where no call spells it out.
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
Default level: deny, because a step of zero never moves the counter, so the loop hangs where it stands.
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 |
|---|---|
enabled |
Default true. false turns the linter off entirely. |
recommended |
Default true. false starts every lint at allow. |
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.groups] |
A level for a whole kind of lint at once. |
[lint.rules] |
Levels by lint name. |
[lint.options] |
Options for unused_variable, high_cyclomatic_complexity, deprecated, restricted_globals, restricted_module_paths, and prefer_const. Also spelled [lint.config], selene's name. |
enabled = false turns the linter off: larvae lint reports nothing and exits zero, and the language server clears the diagnostics of every file instead of leaving the last ones on screen. This is for a project that wants larvae for its formatter and its requires and keeps another linter.
recommended has three states, as Biome has them. Absent and true both mean the default levels apply. false starts every lint at allow, so the project gets the lints it names under [lint.rules] and no others. A level the project wrote always wins in either state, so recommended = false with shadowing = "warn" is one lint on and the rest off. That pair is the reason to reach for it.
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.