|
| 1 | +#!/opt/chef-workstation/embedded/bin/ruby |
| 2 | +# Copyright (c) 2022-present, Meta Platforms, Inc. and affiliates |
| 3 | +# All rights reserved. |
| 4 | +# |
| 5 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 6 | +# you may not use this file except in compliance with the License. |
| 7 | +# You may obtain a copy of the License at |
| 8 | +# |
| 9 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | +# |
| 11 | +# Unless required by applicable law or agreed to in writing, software |
| 12 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 13 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 14 | +# See the License for the specific language governing permissions and |
| 15 | +# limitations under the License. |
| 16 | + |
| 17 | +require 'optparse' |
| 18 | +module Bookworm |
| 19 | + class CLIParser |
| 20 | + def initialize |
| 21 | + parser = ::OptionParser.new |
| 22 | + |
| 23 | + parser.banner = 'Usage: bookworm.rb [options]' |
| 24 | + |
| 25 | + # TODO(dcrosby) explicitly output to stdout? |
| 26 | + # parser.on( |
| 27 | + # '--output TYPE', |
| 28 | + # '(STUB) Configure output type for report. Options: plain (default), JSON', |
| 29 | + # ) |
| 30 | + |
| 31 | + parser.on( |
| 32 | + '--report CLASS', |
| 33 | + "Give the (class) name of the report you'd like", |
| 34 | + ) |
| 35 | + |
| 36 | + parser.on( |
| 37 | + '--list-reports', |
| 38 | + 'Get the (class) names of available reports', |
| 39 | + ) |
| 40 | + |
| 41 | + parser.on( |
| 42 | + '--list-rules', |
| 43 | + 'Get the (class) names of available inference rules', |
| 44 | + ) |
| 45 | + |
| 46 | + parser.separator '' |
| 47 | + parser.separator 'Debugging options:' |
| 48 | + |
| 49 | + # TODO(dcrosby) add verbose mode |
| 50 | + # parser.on( |
| 51 | + # '--verbose', |
| 52 | + # 'Enable verbose mode', |
| 53 | + # ) |
| 54 | + |
| 55 | + parser.on( |
| 56 | + '--profiler', |
| 57 | + 'Enable profiler for performance debugging (requires ruby-prof)', |
| 58 | + ) |
| 59 | + |
| 60 | + parser.on( |
| 61 | + '--irb-config-step', |
| 62 | + 'Open IRB REPL after loading configuration', |
| 63 | + ) |
| 64 | + |
| 65 | + parser.on( |
| 66 | + '--irb-crawl-step', |
| 67 | + 'Open IRB REPL after crawler has run', |
| 68 | + ) |
| 69 | + |
| 70 | + parser.on( |
| 71 | + '--irb-infer-step', |
| 72 | + 'Open IRB REPL after inference has run', |
| 73 | + ) |
| 74 | + |
| 75 | + parser.on( |
| 76 | + '--irb-report-step', |
| 77 | + 'Open IRB REPL after report is generated', |
| 78 | + ) |
| 79 | + |
| 80 | + @parser = parser |
| 81 | + end |
| 82 | + |
| 83 | + def help |
| 84 | + @parser.help |
| 85 | + end |
| 86 | + |
| 87 | + def parse |
| 88 | + options = {} |
| 89 | + @parser.parse(ARGV, :into => options) |
| 90 | + options |
| 91 | + end |
| 92 | + end |
| 93 | +end |
| 94 | +parser = Bookworm::CLIParser.new |
| 95 | +options = parser.parse |
| 96 | + |
| 97 | +if options[:profiler] |
| 98 | + require 'ruby-prof' |
| 99 | + Bookworm::Profile = RubyProf::Profile.new |
| 100 | + Bookworm::Profile.start |
| 101 | +end |
| 102 | + |
| 103 | +# We require the libraries *after* the profiler has a chance to start, |
| 104 | +# also means faster `bookworm -h` response |
| 105 | +require 'set' |
| 106 | +require_relative '../exceptions' |
| 107 | +require_relative '../keys' |
| 108 | +require_relative '../configuration' |
| 109 | +require_relative '../crawler' |
| 110 | +require_relative '../knowledge_base' |
| 111 | +require_relative '../infer_engine' |
| 112 | +require_relative '../report_builder' |
| 113 | + |
| 114 | +module Bookworm |
| 115 | + class ClassLoadError < RuntimeError; end |
| 116 | + |
| 117 | + # Class to hold state of a Bookworm run |
| 118 | + class Run |
| 119 | + attr_reader :cli_help_message, :config, :report_src_dirs, :rule_src_dirs, :action, :irb_breakpoints, :report_name |
| 120 | + |
| 121 | + def initialize(cli_options, cli_help_message) |
| 122 | + @cli_help_message = cli_help_message |
| 123 | + validate_cli_args(cli_options) |
| 124 | + set_irb_breakpoints(cli_options) |
| 125 | + generate_config |
| 126 | + validate_config_file |
| 127 | + load_src_dirs |
| 128 | + determine_action(cli_options) |
| 129 | + binding.irb if irb_breakpoint?('config') # rubocop:disable Lint/Debugger |
| 130 | + end |
| 131 | + |
| 132 | + def set_irb_breakpoints(options) |
| 133 | + @irb_breakpoints = [] |
| 134 | + %w{config crawl infer report}.each do |bp| |
| 135 | + @irb_breakpoints << bp if options["irb-#{bp}-step".to_sym] |
| 136 | + end |
| 137 | + end |
| 138 | + |
| 139 | + def irb_breakpoint?(str) |
| 140 | + @irb_breakpoints.include?(str) |
| 141 | + end |
| 142 | + |
| 143 | + def do_action |
| 144 | + case @action |
| 145 | + when :"list-reports" |
| 146 | + list_reports |
| 147 | + when :"list-rules" |
| 148 | + list_rules |
| 149 | + when :report |
| 150 | + generate_report |
| 151 | + end |
| 152 | + end |
| 153 | + |
| 154 | + def determine_action(options) |
| 155 | + [:"list-reports", :"list-rules", :report].each do |a| |
| 156 | + if options[a] |
| 157 | + if @action |
| 158 | + cli_fail 'Multiple actions specified, check your arguments' |
| 159 | + else |
| 160 | + @action = a |
| 161 | + end |
| 162 | + end |
| 163 | + end |
| 164 | + @report_name = options[:report] |
| 165 | + end |
| 166 | + |
| 167 | + def generate_config |
| 168 | + # TODO(dcrosby) read CLI for config file path |
| 169 | + @config = Bookworm::Configuration.new |
| 170 | + end |
| 171 | + |
| 172 | + def cli_fail(msg) |
| 173 | + puts "#{msg}\n\n#{@cli_help_message}" |
| 174 | + exit(false) |
| 175 | + end |
| 176 | + |
| 177 | + def validate_cli_args(options) |
| 178 | + unless options[:"list-reports"] || options[:"list-rules"] |
| 179 | + unless options[:report] |
| 180 | + cli_fail 'No report name given, take a look at bookworm --list-reports' |
| 181 | + end |
| 182 | + end |
| 183 | + end |
| 184 | + |
| 185 | + def validate_config_file |
| 186 | + if @config.source_dirs.nil? || @config.source_dirs.empty? |
| 187 | + fail 'configuration source_dirs cannot be empty' |
| 188 | + end |
| 189 | + end |
| 190 | + |
| 191 | + def load_src_dirs |
| 192 | + @report_src_dirs = ["#{__dir__}/../reports/"] |
| 193 | + if Dir.exist? "#{@config.system_contrib_dir}/reports" |
| 194 | + @report_src_dirs.append "#{@config.system_contrib_dir}/reports" |
| 195 | + end |
| 196 | + @rule_src_dirs = ["#{__dir__}/../rules/"] |
| 197 | + if Dir.exist? "#{@config.system_contrib_dir}/rules/" |
| 198 | + @rule_src_dirs.append "#{@config.system_contrib_dir}/rules/" |
| 199 | + end |
| 200 | + end |
| 201 | + |
| 202 | + def list_reports |
| 203 | + @report_src_dirs.each do |d| |
| 204 | + Bookworm.load_reports_dir d |
| 205 | + end |
| 206 | + |
| 207 | + puts Bookworm::Reports.constants.map { |x| |
| 208 | + "#{x}\t#{Module.const_get("Bookworm::Reports::#{x}")&.description}" |
| 209 | + }.sort.join("\n") |
| 210 | + end |
| 211 | + |
| 212 | + def list_rules |
| 213 | + @rule_src_dirs.each do |d| |
| 214 | + Bookworm.load_rules_dir d |
| 215 | + end |
| 216 | + puts Bookworm::InferRules.constants.map { |x| |
| 217 | + "#{x}\t#{Module.const_get("Bookworm::InferRules::#{x}")&.description}" |
| 218 | + }.sort.join("\n") |
| 219 | + end |
| 220 | + |
| 221 | + def generate_report |
| 222 | + load_classes_for_report |
| 223 | + crawl_source |
| 224 | + make_inferences |
| 225 | + build_report |
| 226 | + end |
| 227 | + |
| 228 | + def load_classes_for_report |
| 229 | + @report_src_dirs.each do |d| |
| 230 | + |
| 231 | + Bookworm.load_report_class @report_name, :dir => d |
| 232 | + break |
| 233 | + rescue Bookworm::ClassLoadError |
| 234 | + # puts "Unable to load report #{report_name}, take a look at bookworm --list-reports\n\n" |
| 235 | + |
| 236 | + end |
| 237 | + unless Bookworm::Reports.const_defined?(@report_name.to_sym) |
| 238 | + cli_fail "Unable to load report #{@report_name}, take a look at bookworm --list-reports" |
| 239 | + end |
| 240 | + |
| 241 | + # To keep processing to only what is needed, the rules are specified within |
| 242 | + # the report. From those rules, we gather the keys that actually need to be |
| 243 | + # crawled (instead of crawling everything) |
| 244 | + # TODO(dcrosby) recursively check rules for dependency keys |
| 245 | + @rules = Bookworm.get_report_rules(@report_name) |
| 246 | + @rules.each do |rule| |
| 247 | + @rule_src_dirs.each do |d| |
| 248 | + |
| 249 | + Bookworm.load_rule_class rule, :dir => d |
| 250 | + break |
| 251 | + rescue Bookworm::ClassLoadError |
| 252 | + # puts "Unable to load rule #{rule}, take a look at bookworm --list-rules\n\n" |
| 253 | + |
| 254 | + end |
| 255 | + unless Bookworm::InferRules.const_defined?(rule.to_sym) |
| 256 | + cli_fail "Unable to load rule #{rule}, take a look at bookworm --list-rules" |
| 257 | + end |
| 258 | + end |
| 259 | + end |
| 260 | + |
| 261 | + def crawl_source |
| 262 | + # Determine necessary keys to crawl |
| 263 | + keys = @rules.map { |r| Module.const_get("Bookworm::InferRules::#{r}")&.keys }.flatten.uniq |
| 264 | + |
| 265 | + # The crawler determines the files that need to be processed |
| 266 | + # It currently converts Ruby source files to AST/objects (that may change) |
| 267 | + processed_files = Bookworm::Crawler.new(config, :keys => keys).processed_files |
| 268 | + |
| 269 | + # The knowledge base is what we know about the files (AST, paths, |
| 270 | + # digested information from inference rules, etc) |
| 271 | + @knowledge_base = Bookworm::KnowledgeBase.new(processed_files) |
| 272 | + |
| 273 | + binding.irb if irb_breakpoint?('crawl') # rubocop:disable Lint/Debugger |
| 274 | + end |
| 275 | + |
| 276 | + def make_inferences |
| 277 | + # InferEngine takes the crawler output in the knowledge base and runs a series |
| 278 | + # of Infer rules against the source AST (and more) to build a knowledge base |
| 279 | + # around the source |
| 280 | + # It runs classes within the Bookworm::InferRules module namespace |
| 281 | + engine = Bookworm::InferEngine.new(@knowledge_base, @rules) |
| 282 | + @knowledge_base = engine.knowledge_base |
| 283 | + |
| 284 | + binding.irb if irb_breakpoint?('infer') # rubocop:disable Lint/Debugger |
| 285 | + end |
| 286 | + |
| 287 | + def build_report |
| 288 | + # The ReportBuilder takes a knowledge base and generates a report |
| 289 | + # with each class in the Bookworm::Reports module namespace |
| 290 | + Bookworm::ReportBuilder.new(@knowledge_base, @report_name) |
| 291 | + |
| 292 | + binding.irb if irb_breakpoint?('report') # rubocop:disable Lint/Debugger |
| 293 | + end |
| 294 | + end |
| 295 | +end |
| 296 | + |
| 297 | +# TODO refactor this file so the below PROGRAM_NAME hack isn't necessary |
| 298 | +if __FILE__ == $PROGRAM_NAME || $PROGRAM_NAME == './bin/bookworm' || $PROGRAM_NAME == './bookworm.rb' |
| 299 | + run = Bookworm::Run.new(options, parser.help) |
| 300 | + run.do_action |
| 301 | +end |
| 302 | + |
| 303 | +if options[:profiler] |
| 304 | + result = Bookworm::Profile.stop |
| 305 | + printer = RubyProf::GraphPrinter.new(result) |
| 306 | + path = "#{Dir.tmpdir}/bookworm_profile-#{DateTime.now.iso8601(4)}.out" |
| 307 | + printer = ::RubyProf::GraphPrinter.new(result) |
| 308 | + File.open(path, 'w+') do |file| |
| 309 | + printer.print(file) |
| 310 | + end |
| 311 | + puts "Wrote profiler output to #{path}" |
| 312 | +end |
0 commit comments