Friday, May 3, 2024
HomeRuby On RailsMonitoring down not resolving constants in Ruby with parser

Monitoring down not resolving constants in Ruby with parser


Recently, we now have been engaged on upgrading an out of date stack of 1 Ruby app.
This software was working on Ruby 2.4.
After dropping 50 unused gems, performing safety updates, and eliminating deprecation warnings, we determined it was time for a Ruby improve.

That is the place the story REALLY begins, and I encourage you to maintain studying even if you’re not within the previous Ruby model’s internals.
Ultimately, I will provide you with a robust device that can assist you observe down not resolving constants in your codebase.

High-level fixed lookup

One of many main adjustments launched in Ruby 2.5 was eradicating top-level fixed lookup.
It means a breaking change in how Ruby resolves constants. Let me clarify it with an instance.

class A
  class B
  finish
finish

class C
finish

In Ruby 2.4 and earlier, the output of calling A::B::C is C. Are you stunned? I used to be.

[1] pry(principal)> RUBY_VERSION
=> "2.4.5"
[2] pry(principal)> A::B::C
(pry):20: warning: toplevel fixed C referenced by A::B::C
=> C

The warning message means that we’re doing one thing which will have surprising outcomes.

If we tried to do the identical in Ruby 2.5 and later, we might get an error.

[1] pry(principal)> RUBY_VERSION
=> "2.5.9"
[2] pry(principal)> A::B::C
NameError: uninitialized fixed A::B::C
from (pry):8:in `<principal>'

Because the codebase was enormous and poorly examined, we needed to discover a sensible approach to observe down all of the locations the place this alteration would break the app.

It could be comparatively straightforward to grep with regexp all of the constants used within the codebase, however then we needed to discover out in the event that they resolved accurately from the context they’re getting used.

Parser gem

Paweł got here up with the concept to make use of a parser device for this function.
Examples of utilizing this highly effective gem have already been described by us on the weblog.

In brief, it permits parsing Ruby code into an AST (summary syntax tree) after which traversing it.

Processor

We began with extending the Parser::AST::Processor class and overriding the on_const methodology which will get trigerred for each fixed discovered within the code.

class Collector < Parser::AST::Processor
  embody AST::Sexp

  def initialize
    @retailer = Set.new
    @root_path = Rails.root
  finish

  def suspicious_consts
    @retailer.to_a
  finish

  def on_const(node)
    return if node.dad or mum.module_definition?
    return if node.dad or mum.class_definition?

    namespace = node.namespace
    whereas namespace
      return if namespace.lvar_type? # local_variable::SOME_CONSTANT
      return if namespace.send_type? # obj.methodology::SomeClass
      return if namespace.self_type? # self::SOME_CONSTANT
      break if namespace.cbase_type? # we reached the highest stage
      namespace = namespace.namespace
    finish
    const_string = Unparser.unparse(node)

    if node.namespace&.cbase_type?
      return if validate_const(const_string)
    else
      namespace_const_names =
        node
          .each_ancestor
          .choose 
          .map  mod.youngsters.first.const_name 
          .reverse

      (namespace_const_names.dimension + 1).occasions do |i|
        concated = (namespace_const_names[0...namespace_const_names.size - i] + [node.const_name]).be part of("::")
        return if validate_const(concated)
      finish
    finish
    retailer(const_string, node.location)
  finish

  def retailer(const_string, location)
    @retailer << [
      File.join(@root_path, location.name.to_s),
      const_string
    ]
  finish

  def validate_const(namespaced_const_string)
    eval(namespaced_const_string)
    true
  rescue NameError, LoadError
    false
  finish
finish

Guards within the on_const methodology are there to skip constants which can be a part of the category/module definition. We search for usages solely.

return if node.dad or mum.module_definition?
return if node.dad or mum.class_definition?

Than, we drop all of the dynamic usages that are onerous to validate and wish particular dealing with.

namespace = node.namespace
whereas namespace
  return if namespace.lvar_type? # local_variable::SOME_CONSTANT 
  return if namespace.send_type? # obj.methodology::SomeClass
  return if namespace.self_type? # self::SOME_CONSTANT
  break if namespace.cbase_type? # we reached the highest stage
  namespace = namespace.namespace
finish

After that, we test if the filtered-out constants resolve accurately.
If the fixed is explicitly referenced from the top-level, we simply attempt to consider it.
In different instances, we should contemplate the namespace by which the fixed is used and attempt to name it with the total namespace prepended, after which with one stage much less, and so forth, till we attain the highest stage binding.

const_string = Unparser.unparse(node)

if node.namespace&.cbase_type?
  return if validate_const(const_string)
else
  namespace_const_names =
    node
      .each_ancestor
      .choose 
      .map  mod.youngsters.first.const_name 
      .reverse

  (namespace_const_names.dimension + 1).occasions do |i|
    concated = (namespace_const_names[0...namespace_const_names.size - i] + [node.const_name]).be part of("::")
    return if validate_const(concated)
  finish
finish

Lastly, we retailer constants that did not resolve with their location within the codebase.

retailer(const_string, node.location)

Runner

One other class to increase is Parser::Runner which is liable for parsing the information and passing them to the processor.
On the finish, it prints all of the saved suspicious constants.

runner =
  Class.new(Parser::Runner) do
    def runner_name
      "dudu"
    finish

    def course of(buffer)
      parser = @parser_class.new(RuboCop::AST::Builder.new)
      collector = Collector.new
      collector.course of(parser.parse(buffer))
      present(collector.suspicious_consts)
    finish

    def present(assortment)
      return if assortment.empty?
      places
      assortment.every  places pair.be part of("t") 
    finish
  finish

runner.go(ARGV)

Outcomes

We ensured that keen loading is enabled and invoked the script on Ruby 2.4 and a couple of.5 to check the outcomes.

bundle exec ruby collector.rb app/ lib/

It turned out that there have been 52 constants that weren’t resolving accurately in Ruby 2.5 and solely 7 fewer in Ruby 2.4.
It means there have been already 45 potential sources of run-time errors within the codebase which weren’t detectable by present assessments! 🤯

Fortuitously, a few of them have been situated within the code that was not used anymore, so we may simply safely take away these strategies.

Bonus

We printed the script inside the context of the instance app on GitHub.

Test it out at: https://github.com/arkency/constants-resolver.
Copy and run collector.rb in opposition to your codebase and see in case your app is freed from not resolving constants. For those who discover one thing, share this resolution with your folks to assist them keep away from issues too.



RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments