Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ruby: refine rb/uninitialized-local-variable #19205

Merged
merged 13 commits into from
Apr 11, 2025

Conversation

yoff
Copy link
Contributor

@yoff yoff commented Apr 2, 2025

In preparation for shipping as a quality query, this PR removes some false positives for rb/uninitialized-local-variable:

  • there are places where uninitialized reads are intentional (say in 'nil'-checks)
  • there are also some places where they are impossible (e.g. places guarded by 'nil'-checks)

In addition, the query will now only alert if the uninitialized read is also the receiver of a method call.
This is to account for the common practice of producing 'nil' values and deal with them later.
The query now no longer tries to highlight that a 'nil' value may have been produced, but instead tries to highlight when it may not have been correctly dealt with.

The difference in alert counts can be seen here (once that run completes).

The first three rows are

Repository Before After diff
github-github_no-test-dir 3915
artichoke__artichoke 2612
opal__opal 2328

Given the small number of alerts (along with local triage) the precision has been upped to 'high'.

@yoff yoff added the Awaiting evaluation Do not merge yet, this PR is waiting for an evaluation to finish label Apr 2, 2025
@Copilot Copilot bot review requested due to automatic review settings April 2, 2025 15:10
@yoff yoff requested a review from a team as a code owner April 2, 2025 15:10
Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot wasn't able to review any files in this pull request.

Files not reviewed (1)
  • ruby/ql/src/queries/variables/UninitializedLocal.ql: Language not supported

Tip: Copilot code review supports C#, Go, Java, JavaScript, Markdown, Python, Ruby and TypeScript, with more languages coming soon. Learn more

@github-actions github-actions bot added the Ruby label Apr 2, 2025
@yoff yoff marked this pull request as draft April 3, 2025 10:45
@yoff yoff force-pushed the ruby/refine-uninitialised-local branch from ef18662 to 48eae64 Compare April 9, 2025 14:21
Copy link
Contributor

github-actions bot commented Apr 9, 2025

QHelp previews:

ruby/ql/src/queries/variables/UninitializedLocal.qhelp

Potentially uninitialized local variable

In Ruby, raw identifiers like x can be both local variable accesses and method calls. It is a local variable access iff it is syntactically preceded by something that binds it (like an assignment). Consider the following example:

def m
  puts "m"
end

def foo
  m # calls m above
  if false
      m = "0"
      m # reads local variable m
  else
  end
  m.strip # reads uninitialized local variable m, `nil`, and crashes
  m2 # undefined local variable or method 'm2' for main (NameError)
end

This will generate an alert on the last access to m, where it is not clear that the programmer intended to read from the local variable. In fact, the last access to m is a method call, and the value of the local variable is nil, so this will raise a NoMethodError.

Recommendation

Ensure that you check the control and data flow in the method carefully. Add a check for nil before the read, or rewrite the code to ensure that the variable is always initialized before being read.

References

@yoff yoff force-pushed the ruby/refine-uninitialised-local branch from 48eae64 to 9530ac6 Compare April 9, 2025 15:10
@yoff yoff marked this pull request as ready for review April 9, 2025 15:34
Copy link
Contributor

@hvitved hvitved left a comment

Choose a reason for hiding this comment

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

The logic for Boolean operators seems rather complex to me. I wonder if what we are after is allowing for uninitialized accesses, when they happen in a Boolean context, and then also filter out accesses that are guarded by nil checks, such as the implicit nil check of a in a && a.safe.

end

def test_guards
if (a = 3 && a) #$ SPURIOUS: Alert
Copy link
Contributor

Choose a reason for hiding this comment

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

This actually parses as a = (3 && a), so the alert is correct. If you change it to (a = 3) && a the alert disappears.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, that is right, the alert is correct.

else
set_a
end
end until a # OK
Copy link
Contributor

Choose a reason for hiding this comment

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

So the reason that this is OK is because nil is treated as false in a Boolean context?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, this loop will break once a is not nil.

@yoff
Copy link
Contributor Author

yoff commented Apr 10, 2025

The logic for Boolean operators seems rather complex to me. I wonder if what we are after is allowing for uninitialized accesses, when they happen in a Boolean context, and then also filter out accesses that are guarded by nil checks, such as the implicit nil check of a in a && a.safe.

Yes, I think that is a good characterization. I will attempt to express it as two simple predicates in this vein.

@yoff yoff force-pushed the ruby/refine-uninitialised-local branch from 0bc476f to 86d04bd Compare April 11, 2025 01:01
yoff added 2 commits April 11, 2025 03:07
- there are places where uninitialised reads are intentional
- there are also some places where they are impossible
@yoff yoff force-pushed the ruby/refine-uninitialised-local branch from 86d04bd to 8555e8c Compare April 11, 2025 01:07
@yoff
Copy link
Contributor Author

yoff commented Apr 11, 2025

Rewrote and rebased. Thanks for the push, it became much nicer.

@yoff yoff requested a review from hvitved April 11, 2025 01:07
Copy link
Contributor

@hvitved hvitved left a comment

Choose a reason for hiding this comment

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

Thanks a lot for rewriting the query, I agree that it makes it much nicer.

@@ -0,0 +1,4 @@
---
category: queryMetadata
Copy link
Contributor

Choose a reason for hiding this comment

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

I assume this file should be deleted?

Comment on lines 40 to 43
// guard is `!var`
guard.getAstNode().(NotExpr).getOperand() = read.getVariable().getAnAccess() and
branch = false
or
Copy link
Contributor

Choose a reason for hiding this comment

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

This disjunct can be removed; the CFG automatically handles negation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, nice!

Comment on lines 50 to 56
or
// guard is `!var.nil?`
exists(MethodCall c | guard.getAstNode().(NotExpr).getOperand() = c |
c.getReceiver() = read.getVariable().getAnAccess() and
c.getMethodName() = "nil?"
) and
branch = true
Copy link
Contributor

Choose a reason for hiding this comment

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

This disjunct can be removed; the CFG automatically handles negation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Again, nice! (That does deserve repeating...)

Comment on lines 20 to 30
predicate isInBooleanContext(Expr e) {
e = any(ConditionalExpr c).getCondition()
or
e = any(ConditionalLoop l).getCondition()
or
e = any(LogicalAndExpr n).getAnOperand()
or
e = any(LogicalOrExpr n).getAnOperand()
or
e = any(NotExpr n).getOperand()
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This predicate has a lot of overlap with the inBooleanContext predicate from Completion.qll. However, that predicate cannot be used directly, but it highlights some missing cases. I suggest

private predicate inBooleanContext(AstNode n) {
  exists(ConditionalExpr i |
    n = i.getCondition()
    or
    inBooleanContext(i) and
    n = i.getBranch(_)
  )
  or
  n = any(ConditionalLoop parent).getCondition()
  or
  n = any(InClause parent).getCondition()
  or
  n = any(LogicalAndExpr n).getAnOperand()
  or
  n = any(LogicalOrExpr n).getAnOperand()
  or
  n = any(NotExpr n).getOperand()
  or
  n = any(StmtSequence parent | inBooleanContext(parent)).getLastStmt()
  or
  exists(CaseExpr c, WhenClause w |
    not exists(c.getValue()) and
    c.getABranch() = w
  |
    w.getPattern(_) = n
    or
    w = n
  )
}

yoff and others added 6 commits April 11, 2025 10:48
The CFG handles the negation
Co-authored-by: Tom Hvitved <hvitved@github.com>
found during triage
Interviewing a Ruby developer, I learned that
dealing with nil is common practice.
So alerts are mostly useful, if we can point to a place where this has gone wrong.
@yoff yoff requested a review from hvitved April 11, 2025 14:19
@yoff
Copy link
Contributor Author

yoff commented Apr 11, 2025

Started experiment on suite of projects that actually contain alerts (generated with MRVA).

@yoff yoff dismissed hvitved’s stale review April 11, 2025 21:07

Requested changes have been addressed

@yoff yoff merged commit 8552710 into github:main Apr 11, 2025
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting evaluation Do not merge yet, this PR is waiting for an evaluation to finish documentation Ruby
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants