Skip to content

New Linter rule: MisleadingCondition - #2184

Open
tapple wants to merge 20 commits into
luau-lang:masterfrom
tapple:if-validator
Open

tapple wants to merge 20 commits into
luau-lang:masterfrom
tapple:if-validator

Conversation

@tapple

@tapple tapple commented Jan 11, 2026 •

Copy link
Copy Markdown

The linter now complains about condition expressions that are not a supertype of any of:

  1. boolean
  2. nil
  3. true
  4. false

The linter considers a condition expression to be one of:

  1. The condition expression of an if statement
  2. The condition expression of an if expression
  3. The left expression of an and operator
  4. The left expression of an or operator
  5. The first argument to an assert() function call

See the unit test for error messages


Example:

local function llGetAttached(): number
    return 1  -- stub
end
if llGetAttached() then
    print("attached")
else
    print("not attached")
end
luau-analyze MisleadingCondition.luau 
./MisleadingCondition.luau(4,1): MisleadingCondition: (num) is always true; did you mean (num ~= 0)?

@tapple tapple changed the title If validator Linter now warns about non-booleans used in condition expressions Jan 11, 2026
@tapple tapple changed the title Linter now warns about non-booleans used in condition expressions New Linter rule: MisleadingConditions Jan 11, 2026
@jLn0n

jLn0n commented Jan 11, 2026

Copy link
Copy Markdown

this should be documented on a rfc and approved first before creating a new linter rule i think

@WolfGangS

Copy link
Copy Markdown

this should be documented on a rfc and approved first before creating a new linter rule i think

Contribute suggests rfcs only for language changes, this wouldn't do that, and an issue has been created, though maybe some more time should have passed between issue and pr.

On the rule in general, I think it's extremely useful for devs unfamiliar with lua. Nearly every other language has at least 0 as false, and many have a concept of falsey for things like empty strings or tables.

As lua and by extension luau doesn't have a broad falsey concept, this lint would catch a very common footgun for newer users.

Comment thread tests/Linter.test.cpp
@JohnnyMorganz

Copy link
Copy Markdown
Contributor

Can't comment on all the cases, but I like the idea. I think this is also valuable for a case where you have a function foo that returns a boolean, and you accidentally write if foo then instead of if foo() then, which will always be true. This has caused prod incidents before

@tapple tapple changed the title New Linter rule: MisleadingConditions New Linter rule: MisleadingCondition Jan 13, 2026
@aatxe

aatxe commented Jan 13, 2026

Copy link
Copy Markdown
Member

this should be documented on a rfc and approved first before creating a new linter rule i think

The linter is not part of the language itself, and frankly will probably be sunset in favor of the linter we're writing in lute since that allows programmers to author their own lints in luau instead of writing C++ to lint luau. Even if it continues to exist as well, it's not something we've treated as requiring an RFC.

Comment thread Analysis/src/Linter.cpp
{
const char * msg;
bool negated;
if (!checkCondition(node->condition, &msg, &negated))

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: also check the first argument to assert()

@tapple tapple Jan 18, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe don't. An assert evaluating false is usually understood to be a bug. And the typechecker should often be able to catch the same bugs (at leasts the asserts that assert not nil). So nearly every time this linter rule flagged an assert, it would be a false warning.

also, asserts are pretty specialized. this linter rule is meant to catch common, stupid errors. And I don't think the linter even could distinguish between "asserts that meant to check a boolean but forgot a comparison operator" and "asserts that are checking for non-nil and the type-checker agrees". Maybe checking for intersection types, but either way, it's different enough that it should be a different linter rule

@aatxe aatxe Jan 20, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An assert evaluating false is usually understood to be a bug.

I don't think this is true at all. We use things like assert(false) or assert("this message to read about an unreachable assumption" && false) all the time to mark branches that we expect to be unreachable.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added assert() to the list of expressions the linter checks: 29817e7

Comment thread Analysis/src/Linter.cpp
@tapple
tapple marked this pull request as draft June 27, 2026 20:36
@tapple
tapple marked this pull request as ready for review July 7, 2026 18:25
Comment thread Analysis/src/Linter.cpp
}
};

class LintMisleadingCondition : AstVisitor

@tapple tapple Jul 10, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 month status report:

I'm still finding this linter rule useful enough that I keep bothering to:

  1. rebase it on new luau versions
  2. keep building luau-lsp and it's vscode plugin locally so I can use it
  3. Maintain this branch as luau moves forward

@aatxe mentioned above #2184 (comment) that this rule should be migrated to the lute linter. I'd be happy to do that at some point. However, it's lower priority to me than teaching lute to use definitions files. Until then, lute is useless to me.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lute check now knows about definitions files, so porting this to lute is now higher priority for me

@aatxe aatxe left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overall implementation here feels messy and full of bugs and unexplained inconsistencies. At a high level, a lint for conditions that are both unintended and likely unexpectedly wrong is a good thing, but the realization of that here isn't up to our quality bar.

Comment thread Analysis/src/Linter.cpp
Comment on lines +3638 to +3650
bool visit(AstExprCall* node) override
{
const char * msg;
bool negated;
if (const auto* global = node->func->as<AstExprGlobal>())
if (strcmp(global->name.value, "assert") == 0)
if (node->args.size >= 1)
if (!checkCondition(node->args.data[0], &msg, &negated))
emitWarning(*context, LintWarning::Code_MisleadingCondition, node->location, "%s", msg);

return true;
}
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned when you were only considering it, I do not think this is a reasonable addition to this lint. It is commonplace to use assert(false) for unreachable, and it will produce this lint as noise on every such instance of that.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert(false) does not trigger a warning. It doesn't meet my definition of misleading. there's even a unit test to make sure it doesn't

Valid conditions include one of the following as a union member:

  1. boolean
  2. true
  3. false
  4. nil

If those are all missing, it is labeled misleading

Things like assert(0) or assert("response") would trigger a warning

Comment thread Analysis/src/Linter.cpp Outdated
const char * msg;
bool negated;
if (const auto* global = node->func->as<AstExprGlobal>())
if (strcmp(global->name.value, "assert") == 0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches every global function named assert, including shadowed ones, and you modified a test to mask this.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It also is again using strcmp instead of an ordinary comparison. global->name == "assert".

@tapple tapple Aug 4, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll remove the strcmp

I wonder if the typechecker can identify the global assert function. I would assume not. I assume the typechecker would just see the name assert and the type <T>(T, string?): T. Maybe I could identify it by MagicAssert

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed strcmp: b6a8e6d

Comment thread Analysis/src/Linter.cpp Outdated
Comment thread Analysis/src/Linter.cpp Outdated
Comment thread Analysis/src/Linter.cpp
Comment on lines +3533 to +3539
*negated = false;
if (const auto* unary = cond->as<AstExprUnary>())
if (unary-> op == AstExprUnary::Op::Not)
{
*negated = true;
cond = unary->expr;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hard-codes exactly one not, which means weird things will happen for any chains of not, like if not not 0 then.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add unit tests for this case and see what happens

Comment thread Analysis/src/Linter.cpp

if (isNumber(*type))
{
*msg = *negated ? "(not num) is always false; did you mean (num == 0)?" : "(num) is always true; did you mean (num ~= 0)?";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think any of these error messages are acceptable in their current form. We don't generally produce errors that use metavariables (like you're doing here with num, and like you're doing across all of the errors). More effective errors would refer to things the user actually wrote instead.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. I could not figure out the correct incantation to get the original source code at the expression start...end. I'll look again and try to do that

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh. I also was very unsure how to both

  1. allocate the space for a dynamic string, and especially
  2. clean up the space some time later for a dynamic string

All the other lint rules used statically-allocated strings, which don't need cleanup, so I did too

Comment thread tests/Linter.test.cpp Outdated
Comment thread tests/Linter.test.cpp Outdated
Comment on lines +2378 to +2382
if if 1 then 2 else 3 then
elseif if 1 then 2 else 3 then
elseif if 0 then 5 else 4 then
a = true; b = true; c = false; d = true; e = false; f = true
if if b then c else d then
elseif if b then c else d then
elseif if a then f else e then

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You shouldn't be changing this test either.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this change would be unnecessary if number | true was treated as a valid condition, as requested in #2184 (comment)

@tapple tapple Aug 20, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reverted this test, and added --!nolint: a3c91d9. It fails MisleadingCondition 6 times, correctly in my opinion:

  1. 1
  2. 2
  3. 1
  4. 2
  5. 0
  6. 5

Comment thread tests/Linter.test.cpp Outdated
Comment thread tests/Linter.test.cpp
_ = (true and true) and true
_ = (true and true) or true
_ = (true and false) and (42 and false)
_ = (true and false) and (42 and false) -- also fails MisleadingCondition

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an indication that misleading condition, as you have written it, does not work. We shouldn't be disabling the lint in our tests to mask bugs in the lint. Our users will actually experience the lints.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this change would be unnecessary if number | true was treated as a valid condition, as requested in #2184 (comment)

@tapple tapple Aug 20, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm. I think this should actually fail misleading condition. 42 is not a supertype of nil, boolean, true, or false.

and in this case is only really acting as an expression seperator, to unconditionally change the expression value. If this is idiomatic usage, maybe "the left side of and" should not be checked by MisleadingCondition

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in this specific expression (42 and false) will never actually be evaluated, because the left side of the top-level and ((true and false)) is always false. The linter isn't that smart though

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Linter should check for non-booleans in condition expressions

5 participants