Ruby: Flipper Feature Flag Discovery with AST parsing
So you have Flipper in your ruby codebase for handling feature flags but… now you have used them and need a way to find them and help you pay down the tech debt of cleaning them up.
Well good news, you can use AST Parsing to find all the instance.
This simple parser walks all the Ruby code looking for calls to Flipper.enabled?
and makes a list of the features flags used.
class FlipperEnableFinder < Parser::AST::Processor
include RuboCop::AST::Traversal
sig { returns(T::Array[Symbol]) }
attr_reader :found_features
PATTERN = <<~PATTERN
(send (const {nil? (cbase)} :Flipper) :enabled? sym ...)
PATTERN
sig { void }
def initialize
@found_features = T.let([], T::Array[Symbol])
end
sig { params(node: T.untyped).returns(T.untyped) }
def on_send(node)
if RuboCop::AST::NodePattern.new(PATTERN).match(node)
feature = node.children.detect { |c| c.respond_to?(:type) && c.type == :sym }.children.first
@found_features << feature
end
end
end
So then all we need to do is pull in all the ruby code and pass it through the AST rule.
This is a little slow so using parallel_ruby
helps us out here and some pre-processing to get a smaller list of Ruby files to parse with our AST processor.
Full implementation looks like this:
class Usage
# Skip dirs that don't have any ruby
EXCLUDED_DIRS = %w[.npm coverage .git bin public tmp vendor node_modules script spec sorbet]
class FlipperEnableFinder < Parser::AST::Processor
include RuboCop::AST::Traversal
sig { returns(T::Array[Symbol]) }
attr_reader :found_features
PATTERN = <<~PATTERN
(send (const {nil? (cbase)} :Flipper) :enabled? sym ...)
PATTERN
sig { void }
def initialize
@found_features = T.let([], T::Array[Symbol])
end
sig { params(node: T.untyped).returns(T.untyped) }
def on_send(node)
if RuboCop::AST::NodePattern.new(PATTERN).match(node)
feature = node.children.detect { |c| c.respond_to?(:type) && c.type == :sym }.children.first
@found_features << feature
end
end
end
# Cache the result once you have it as its expensive to compute
@@flipper_features_result = T.let(nil, T.nilable(T::Array[Symbol]))
sig { returns(T::Array[Symbol]) }
def self.find_all_from_code
return @@flipper_features_result unless @@flipper_features_result.nil?
# As AST parsing is expensive, do a first pass with glob and checking content as these
# are cheap
files_with_flags_used = Dir.glob("./**/*.rb").filter_map { |f|
dirs = Pathname.new(f).dirname.to_s.split("/")
next if dirs.any? { |dir| EXCLUDED_DIRS.include?(dir) }
content = File.read(f)
content if content.match(/Flipper.enabled\?.*/)
}
# Now we have a small list lets parse the AST and find our flags
found_features = Parallel.map(files_with_flags_used, in_processes: 32) do |file|
rule = FlipperEnableFinder.new
source = RuboCop::AST::ProcessedSource.new(file, 3.1)
source.ast.each_node do |node|
rule.process(node)
end
rule.found_features
end
@@flipper_features_result = found_features.flatten.uniq
return @@flipper_features_result
end
end