Skip to content

unused function lint for recursive functions - #2553

Open
nnullcolumn wants to merge 9 commits into
luau-lang:masterfrom
nnullcolumn:enhance-unused-function-lint
Open

unused function lint for recursive functions#2553
nnullcolumn wants to merge 9 commits into
luau-lang:masterfrom
nnullcolumn:enhance-unused-function-lint

Conversation

@nnullcolumn

Copy link
Copy Markdown
Contributor

@hgoldstein hgoldstein self-assigned this Aug 4, 2026

@hgoldstein hgoldstein left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This needs to be flagged, but otherwise I like the direction.

Comment thread tests/Linter.test.cpp
Comment thread Analysis/src/Linter.cpp
l.function = true;

return true;
l.scopeDepth++;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If I write:

local function foobar() -- Let's call this foobar_0
    local function foobar() -- And this foobar_1
        return foobar()
    end
end

return foobar()

... foobar_0 and foobar_1 are different AstLocal*s. Luau's parser handles this sorta thing for you. I don't think you need to track scope depth here at all.

@nnullcolumn nnullcolumn Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

scope depth was the chosen mechanism for detecting whether the reference was inside the body of the function or not. i think i could change this to use the Location of the function's body, instead? at the time it seemed the most intuitive to me (i think it wasn't meant to get around anything in the local case, i more or less use the depth like a boolean here but that seems less intuitive than using the span of the body now)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You could probably get away with a single boolean flag is my point, there shouldn't be a way to see a scope depth that isn't either 0 or 1.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

sorry i don't think i communicated very well; yeah, i could, but there's a rare case i'd actually probably like to add tests for. it might break if someone wrote speculative code at runtime that changes the definition:

local function fun()
    if cond then
        -- same local
        function fun()
        end
        -- exits scope
    else
        -- same local
        function fun()
        end
        -- exists scope
    end
    -- function considered used
    return fun()
end
-- sets the scope to false even though it already left

admittedly im sure this pattern is pretty rare in user code, but as a general thing i often prefer counters or spans over straight up booleans when traversing things because it makes the worstcase of "things are stepping over eachother in ways we didn't expect or that this code wasnt originally written for" much less catastrophic. this might also be more likely e.g. if someone updated the lint to support forward declaration

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Oh I see what you mean: if you'd like to support this, sure.

Comment thread Analysis/src/Linter.cpp Outdated
bool function = false;
bool import = false;
bool used = false;
bool softUsed = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe usedAsRecursiveCall instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

i've updated it to usedOutsideSelf and usedRecursively respectively if that's alright.

Comment thread Analysis/src/Linter.cpp
Location location;
bool function;
bool used;
unsigned int scopeDepth = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You might need scope depth for globals?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

i think i'd prefer to keep the global handling simple if there aren't any apparent issues with it

@nnullcolumn
nnullcolumn requested a review from a team as a code owner August 8, 2026 12:49
@nnullcolumn
nnullcolumn requested a review from vegorov-rbx August 8, 2026 12:49
@nnullcolumn

Copy link
Copy Markdown
Contributor Author

This needs to be flagged, but otherwise I like the direction.

i flagged it

@nnullcolumn

nnullcolumn commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

idk why it auto requested review from vegorov sry that may have been a misclick or something? dunno

EDIT: oh theres a new CODEOWNERS in the repo thats kinda cool

@nnullcolumn

Copy link
Copy Markdown
Contributor Author

unification_runs_a_limited_number_of_iterations_before_stopping_subtyping failed on windows

lmfao

Comment thread tests/Linter.test.cpp
end
)");

REQUIRE(0 == result.warnings.size());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this test case was accidentally duplicated / incomplete. whoops

@hgoldstein hgoldstein left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This still looks like a bunch of the changes are not flagged? I would also advise against mixing refactors like renaming variables or providing defaults to struct members with other changes.

@nnullcolumn

Copy link
Copy Markdown
Contributor Author

This still looks like a bunch of the changes are not flagged?

I'll look over it again, i guess i was a bit sleep deprived at the time

I would also advise against mixing refactors like renaming variables or providing defaults to struct members with other changes.

can you point to a specific commit hash? the three commits since your last review split it up (there was a dedicated commit for the renames)

@nnullcolumn

Copy link
Copy Markdown
Contributor Author

This still looks like a bunch of the changes are not flagged?

I'll look over it again, i guess i was a bit sleep deprived at the time

took a look now. there are two places where usedRecursively is set = true, so the flag code i added line 905 and 1034 just fall back to setting a 'external' reference (effectively disabling the change because that was the old behavior)

i think adding the flag in other places would obscure the control flow a bit but i can do that if it's wanted

@hgoldstein hgoldstein left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i think adding the flag in other places would obscure the control flow a bit but i can do that if it's wanted

When we flag code, we try to make sure that the code path is exactly the same when the flag has been set to off. There are some cases where we fudge things slightly, but it's meant to be extremely conservative. For example, even adding fields to LintLocalHygiene::Local like you've done is technically "unflagged." This maps to our internal process for gauging risk: things that are entirely flagged are minimally risky, less likely to cause issues on rollout, require extensive QA, etc.

That's part of why I ask not to mix refactoring and other changes. Mixing the two makes it really hard to ensure that the flag-off path is exactly the same.

Comment thread Analysis/src/Linter.cpp
bool import = false;
bool usedOutsideSelf = false;
bool usedRecursively = false;
bool arg = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is what I mean by "don't mix refactoring and other changes." Optimally this would be:

        AstNode* defined = nullptr;
        bool function;
        bool import;
        bool used;
        bool arg;
        bool usedRecursively = false;
        unsigned int scopeDepth = 0;
  • Only make the changes you need, don't mix in renaming fields or adding defaults.
  • Just add struct fields at the end rather than also changing the order of fields.

@nnullcolumn nnullcolumn Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Just add struct fields at the end rather than also changing the order of fields

sorry, ocd probably. i'll try to do that in the future

Only make the changes you need, don't mix in renaming fields or adding defaults.

the defaults were added aggressively here after i experienced a bug w uninitialized values:

fefc694

the renames were done here, in their own dedicated commit:

d7d2be8

are you suggesting that my changes as a whole shouldn't rename things in the linter? if so, that seems silly - the previous names didn't make as much sense with new fields added, so i changed them

EDIT: silly me, i missed part of the review comment when i wrote that

Comment thread Analysis/src/Linter.cpp
l.function = true;

return true;
l.scopeDepth++;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Oh I see what you mean: if you'd like to support this, sure.

@nnullcolumn

nnullcolumn commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

i think adding the flag in other places would obscure the control flow a bit but i can do that if it's wanted

When we flag code, we try to make sure that the code path is exactly the same when the flag has been set to off. There are some cases where we fudge things slightly, but it's meant to be extremely conservative. For example, even adding fields to LintLocalHygiene::Local like you've done is technically "unflagged." This maps to our internal process for gauging risk: things that are entirely flagged are minimally risky, less likely to cause issues on rollout, require extensive QA, etc.

That's part of why I ask not to mix refactoring and other changes. Mixing the two makes it really hard to ensure that the flag-off path is exactly the same.

that makes complete sense, yeah. i'll adjust some things to make the original code more clearly specialized with the flag. Surely keeping the off-path the same isn't entirely realistic all the time though, is it? i feel like to do it properly i'd need to make a second copy of every field which i use differently

@hgoldstein

Copy link
Copy Markdown
Contributor

Surely keeping the off-path the same isn't entirely realistic all the time though, is it? i feel like to do it properly i'd need to make a second copy of every field which i use differently

It's not, no. Any time you're adding a field to an existing struct, for example, there's a mild risk. The one the Luau team has run into is that even "entirely flagged" blocks in function calls have an effect on the stack size for that function. If you look in Subtyping.cpp there are a bunch of places where we use std::unique_ptr<SubtypingResult> on the stack. Part of the reasoning there was that a previous flagged change caused us to stack overflow more often.

@nnullcolumn

Copy link
Copy Markdown
Contributor Author

Any time you're adding a field to an existing struct, for example, there's a mild risk. The one the Luau team has run into is that even "entirely flagged" blocks in function calls have an effect on the stack size for that function.

that's terrifying and fascinating, i can't imagine what kinds of niche failure cases y'all have hit. my approach to flagging this was a bit of a shortcut and i knew it. it can be easy to lose sight of why it's important and view stuff like that as a chore that you can get away with sweeping under the rug, but seeing a team that cares is a breath of fresh air in a world full of LGTMs and LLMs. i'll do my best to match that patience, care, and attention to detail with contributions going forward.

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.

unused function lint fails for recursive function

2 participants