quiet_quality 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9c943644f5453e92b2b839bf42f8f031f67b13d6c8385c89822b17b02dccf60
4
- data.tar.gz: 780325cd5db5fdac051880d404756887e60882929503e76876f178945d167f92
3
+ metadata.gz: 92fb13c8040117b5c0117af41e7253d793c2818a05c4d69b8f522677f20b96a7
4
+ data.tar.gz: 27da41d1430f7a09a22444fa117904f231b8feec6006c48393bd9d0cf9859707
5
5
  SHA512:
6
- metadata.gz: 3afd25c6af0810f3c6195ba0947173be2b926e1d689d7e095458c18e97e445a40935ce62cb730195a0b33a93f57568d4328a4260651183df08a7cac41b5cb8eb
7
- data.tar.gz: c345f28884539737a9d37b1aaa8480ee06957c43d6b83b8eaa7d302855285f3f098f8f2b110b880da54cfdec7e4105a81a9701358494e92b9425849fc7231576
6
+ metadata.gz: 020bb9aa46fc7a1d5989dbf7f03538533b6f54006e23003f90209a6baacbb03c015cee81f00893047bc7c39260788d62a4380157ee95b610acd08acd64746569
7
+ data.tar.gz: 68d20464b4f83e819ef8e8128420e84cadb857183238167aa4a9b4947890d571ec636836656bc728dc3b459b75668152e1c510f034a8ddc8d49e5403150ec3b3
@@ -0,0 +1,6 @@
1
+ ---
2
+ default_tools: ["standardrb", "rubocop", "rspec"]
3
+ executor: concurrent
4
+ comparison_branch: main
5
+ changed_files: false
6
+ filter_messages: false
data/bin/qq CHANGED
@@ -1,35 +1,3 @@
1
1
  #!/usr/bin/env ruby
2
2
  require_relative "../lib/quiet_quality"
3
-
4
- opt_parser = QuietQuality::Cli::OptionParser.new(ARGV)
5
- tool_names, global_options, tool_options = opt_parser.parse!
6
- options = QuietQuality::Cli::OptionsBuilder.new(tool_names: tool_names, global_options: global_options, tool_options: tool_options).options
7
-
8
- executor = options.executor.new(tools: options.tools)
9
- executor.execute!
10
-
11
- executor.outcomes.each do |outcome|
12
- result = outcome.success? ? "Passed" : "Failed"
13
- warn "--- #{result}: #{outcome.tool}"
14
- end
15
-
16
- messages = executor.messages
17
- if messages.any?
18
- warn "\n\n#{messages.count} messages:"
19
- messages.each do |msg|
20
- line_range = msg.start_line == msg.stop_line ? msg.start_line.to_s : "#{msg.start_line}-#{msg.stop_line}"
21
- body = msg.body.gsub(/ *\n */, "\\n").slice(0, 120)
22
- warn " #{msg.path}:#{line_range} #{msg.rule}"
23
- warn " #{body}"
24
- end
25
- end
26
-
27
- if options.annotator
28
- warn "\n\n"
29
- options.annotator.new.annotate!(messages)
30
- end
31
-
32
- if executor.any_failure?
33
- warn "failures detected in one or more tools"
34
- exit(1)
35
- end
3
+ exit(1) unless QuietQuality::Cli::Entrypoint.new(argv: ARGV).execute.successful?
@@ -0,0 +1,135 @@
1
+ require "optparse"
2
+
3
+ module QuietQuality
4
+ module Cli
5
+ class ArgParser
6
+ def initialize(args)
7
+ @args = args
8
+ @parsed_options = Config::ParsedOptions.new
9
+ @parsed = false
10
+ end
11
+
12
+ def parsed_options
13
+ unless @parsed
14
+ parser.parse!(@args)
15
+ @parsed_options.tools = validated_tool_names(@args.dup).map(&:to_sym)
16
+ @parsed = true
17
+ end
18
+ @parsed_options
19
+ end
20
+
21
+ def help_text
22
+ @_help_text ||= parser.to_s
23
+ end
24
+
25
+ private
26
+
27
+ def set_global_option(name, value)
28
+ @parsed_options.set_global_option(name, value)
29
+ end
30
+
31
+ def set_tool_option(tool, name, value)
32
+ @parsed_options.set_tool_option(tool, name, value)
33
+ end
34
+
35
+ def validate_value_from(name, value, allowed)
36
+ return if allowed.include?(value.to_sym)
37
+ fail(UsageError, "Unrecognized #{name}: #{value}")
38
+ end
39
+
40
+ def validated_tool_names(names)
41
+ names.each { |name| validate_value_from("tool", name, Tools::AVAILABLE) }
42
+ end
43
+
44
+ # There are several flags that _may_ take a 'tool' argument - if they do, they are tool
45
+ # options; if they don't, they are global options. (optparse allows an optional argument
46
+ # to a flag if the string representing it is not a 'string in all caps'. So `[FOO]` or `foo`
47
+ # would be optional, but `FOO` would be required. This helper simplifies handling those.
48
+ def read_tool_or_global_option(name, tool, value)
49
+ if tool
50
+ validate_value_from("tool", tool, Tools::AVAILABLE)
51
+ set_tool_option(tool, name, value)
52
+ else
53
+ set_global_option(name, value)
54
+ end
55
+ end
56
+
57
+ # -- Set up the option parser itself -------------------------
58
+
59
+ def parser
60
+ @_parser ||= ::OptionParser.new do |parser|
61
+ setup_banner(parser)
62
+ setup_help_output(parser)
63
+ setup_config_options(parser)
64
+ setup_executor_options(parser)
65
+ setup_annotation_options(parser)
66
+ setup_file_target_options(parser)
67
+ setup_filter_messages_options(parser)
68
+ end
69
+ end
70
+
71
+ def setup_banner(parser)
72
+ parser.banner = "Usage: qq [TOOLS] [GLOBAL_OPTIONS] [TOOL_OPTIONS]"
73
+ end
74
+
75
+ def setup_help_output(parser)
76
+ parser.on("-h", "--help", "Prints this help") do
77
+ @parsed_options.helping = true
78
+ end
79
+ end
80
+
81
+ def setup_config_options(parser)
82
+ parser.on("-C", "--config PATH", "Load a config file from this path") do |path|
83
+ set_global_option(:config_path, path)
84
+ end
85
+
86
+ parser.on("-N", "--no-config", "Do not load a config file, even if present") do
87
+ set_global_option(:no_config, true)
88
+ end
89
+ end
90
+
91
+ def setup_executor_options(parser)
92
+ parser.on("-E", "--executor EXECUTOR", "Which executor to use") do |name|
93
+ validate_value_from("executor", name, Executors::AVAILABLE)
94
+ set_global_option(:executor, name.to_sym)
95
+ end
96
+ end
97
+
98
+ def setup_annotation_options(parser)
99
+ parser.on("-A", "--annotate ANNOTATOR", "Annotate with this annotator") do |name|
100
+ validate_value_from("annotator", name, Annotators::ANNOTATOR_TYPES)
101
+ set_global_option(:annotator, name.to_sym)
102
+ end
103
+
104
+ # shortcut option
105
+ parser.on("-G", "--annotate-github-stdout", "Annotate with GitHub Workflow commands") do
106
+ set_global_option(:annotator, :github_stdout)
107
+ end
108
+ end
109
+
110
+ def setup_file_target_options(parser)
111
+ parser.on("-a", "--all-files [tool]", "Use the tool(s) on all files") do |tool|
112
+ read_tool_or_global_option(:all_files, tool, true)
113
+ end
114
+
115
+ parser.on("-c", "--changed-files [tool]", "Use the tool(s) only on changed files") do |tool|
116
+ read_tool_or_global_option(:all_files, tool, false)
117
+ end
118
+
119
+ parser.on("-B", "--comparison-branch BRANCH", "Specify the branch to compare against") do |branch|
120
+ set_global_option(:comparison_branch, branch)
121
+ end
122
+ end
123
+
124
+ def setup_filter_messages_options(parser)
125
+ parser.on("-f", "--filter-messages [tool]", "Filter messages from tool(s) based on changed lines") do |tool|
126
+ read_tool_or_global_option(:filter_messages, tool, true)
127
+ end
128
+
129
+ parser.on("-u", "--unfiltered [tool]", "Don't filter messages from tool(s)") do |tool|
130
+ read_tool_or_global_option(:filter_messages, tool, false)
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,95 @@
1
+ module QuietQuality
2
+ module Cli
3
+ class Entrypoint
4
+ def initialize(argv:, output_stream: $stdout, error_stream: $stderr)
5
+ @argv = argv
6
+ @output_stream = output_stream
7
+ @error_stream = error_stream
8
+ end
9
+
10
+ def execute
11
+ if helping?
12
+ log_help_text
13
+ else
14
+ executed
15
+ log_outcomes
16
+ log_messages
17
+ annotate_messages
18
+ end
19
+
20
+ self
21
+ end
22
+
23
+ def successful?
24
+ helping? || !executed.any_failure?
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :argv, :output_stream, :error_stream
30
+
31
+ def arg_parser
32
+ @_arg_parser ||= ArgParser.new(argv.dup)
33
+ end
34
+
35
+ def parsed_options
36
+ @_parsed_options ||= arg_parser.parsed_options
37
+ end
38
+
39
+ def helping?
40
+ parsed_options.helping?
41
+ end
42
+
43
+ def log_help_text
44
+ error_stream.puts(arg_parser.help_text)
45
+ end
46
+
47
+ def options
48
+ return @_options if defined?(@_options)
49
+ builder = Config::Builder.new(parsed_cli_options: parsed_options)
50
+ @_options = builder.options
51
+ end
52
+
53
+ def executor
54
+ @_executor ||= options.executor.new(tools: options.tools)
55
+ end
56
+
57
+ def executed
58
+ return @_executed if defined?(@_executed)
59
+ executor.execute!
60
+ @_executed = executor
61
+ end
62
+
63
+ def log_outcomes
64
+ executed.outcomes.each do |outcome|
65
+ result = outcome.success? ? "Passed" : "Failed"
66
+ error_stream.puts "--- #{result}: #{outcome.tool}"
67
+ end
68
+ end
69
+
70
+ def log_message(msg)
71
+ line_range =
72
+ if msg.start_line == msg.stop_line
73
+ msg.start_line.to_s
74
+ else
75
+ "#{msg.start_line}-#{msg.stop_line}"
76
+ end
77
+ rule_string = msg.rule ? " [#{msg.rule}]" : ""
78
+ truncated_body = msg.body.gsub(/ *\n */, "\\n").slice(0, 120)
79
+ error_stream.puts " #{msg.path}:#{line_range}#{rule_string} #{truncated_body}"
80
+ end
81
+
82
+ def log_messages
83
+ return unless executed.messages.any?
84
+ error_stream.puts "\n\n#{executed.messages.count} messages:"
85
+ executed.messages.each { |msg| log_message(msg) }
86
+ end
87
+
88
+ def annotate_messages
89
+ return unless options.annotator
90
+ annotator = options.annotator.new(output_stream: output_stream)
91
+ annotator.annotate!(executed.messages)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,123 @@
1
+ module QuietQuality
2
+ module Config
3
+ class Builder
4
+ def initialize(parsed_cli_options:)
5
+ @cli = parsed_cli_options
6
+ end
7
+
8
+ def options
9
+ return @_options if defined?(@_options)
10
+ options = build_initial_options
11
+ Updater.new(options: options, apply: config_file).update! if config_file
12
+ Updater.new(options: options, apply: cli).update!
13
+ @_options = options
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :cli
19
+
20
+ def build_initial_options
21
+ tools = tool_names.map { |name| ToolOptions.new(name) }
22
+ Options.new.tap { |opts| opts.tools = tools }
23
+ end
24
+
25
+ def tool_names
26
+ if cli.tools.any?
27
+ cli.tools
28
+ elsif config_file&.tools&.any?
29
+ config_file.tools
30
+ else
31
+ Tools::AVAILABLE.keys
32
+ end
33
+ end
34
+
35
+ def config_finder
36
+ @_config_finder ||= Finder.new(from: ".")
37
+ end
38
+
39
+ def config_path
40
+ return @_config_path if defined?(@_config_path)
41
+
42
+ @_config_path =
43
+ if cli.global_option(:no_config)
44
+ nil
45
+ elsif cli.global_option(:config_path)
46
+ cli.global_option(:config_path)
47
+ elsif config_finder.config_path
48
+ config_finder.config_path
49
+ end
50
+ end
51
+
52
+ def config_file
53
+ return @_parsed_config_options if defined?(@_parsed_config_options)
54
+
55
+ if config_path
56
+ parser = Parser.new(config_path)
57
+ @_parsed_config_options = parser.parsed_options
58
+ else
59
+ @_parsed_config_options = nil
60
+ end
61
+ end
62
+
63
+ class Updater
64
+ def initialize(options:, apply:)
65
+ @options, @apply = options, apply
66
+ end
67
+
68
+ def update!
69
+ update_globals
70
+ update_tools
71
+ end
72
+
73
+ private
74
+
75
+ attr_reader :options, :apply
76
+
77
+ def set_unless_nil(object, method, value)
78
+ return if value.nil?
79
+ object.send("#{method}=", value)
80
+ end
81
+
82
+ # ---- update the global options -------------
83
+
84
+ def update_globals
85
+ update_annotator
86
+ update_executor
87
+ update_comparison_branch
88
+ end
89
+
90
+ def update_annotator
91
+ annotator_name = apply.global_option(:annotator)
92
+ return if annotator_name.nil?
93
+ options.annotator = Annotators::ANNOTATOR_TYPES.fetch(annotator_name)
94
+ end
95
+
96
+ def update_executor
97
+ executor_name = apply.global_option(:executor)
98
+ return if executor_name.nil?
99
+ options.executor = Executors::AVAILABLE.fetch(executor_name)
100
+ end
101
+
102
+ def update_comparison_branch
103
+ set_unless_nil(options, :comparison_branch, apply.global_option(:comparison_branch))
104
+ end
105
+
106
+ # ---- update the tool options (apply global forms first) -------
107
+
108
+ def update_tools
109
+ options.tools.each do |tool_options|
110
+ update_tool_option(tool_options, :limit_targets)
111
+ update_tool_option(tool_options, :filter_messages)
112
+ end
113
+ end
114
+
115
+ def update_tool_option(tool_options, option_name)
116
+ tool_name = tool_options.tool_name
117
+ set_unless_nil(tool_options, option_name, apply.global_option(option_name))
118
+ set_unless_nil(tool_options, option_name, apply.tool_option(tool_name, option_name))
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,45 @@
1
+ module QuietQuality
2
+ module Config
3
+ class Finder
4
+ CONFIG_FILENAME = ".quiet_quality.yml"
5
+ MAXIMUM_SEARCH_DEPTH = 100
6
+
7
+ def initialize(from:)
8
+ @from = from
9
+ end
10
+
11
+ def config_path
12
+ return @_config_path if defined?(@_config_path)
13
+ each_successive_enclosing_directory do |dir_path|
14
+ file_path = dir_path.join(CONFIG_FILENAME)
15
+ if file_path.exist?
16
+ return @_config_path = file_path.to_s
17
+ end
18
+ end
19
+ @_config_path = nil
20
+ rescue Errno::EACCES
21
+ @_config_path = nil
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :from
27
+
28
+ def config_path_within(dir)
29
+ File.join(dir, CONFIG_FILENAME)
30
+ end
31
+
32
+ def each_successive_enclosing_directory(max_depth: 100, &block)
33
+ d = Pathname.new(from)
34
+ depth = 0
35
+ MAXIMUM_SEARCH_DEPTH.times do
36
+ block.call(d.expand_path)
37
+ d = d.parent
38
+ depth += 1
39
+ return nil if d.root?
40
+ end
41
+ nil
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,14 @@
1
+ module QuietQuality
2
+ module Config
3
+ class Options
4
+ def initialize
5
+ @annotator = nil
6
+ @executor = Executors::ConcurrentExecutor
7
+ @tools = nil
8
+ @comparison_branch = nil
9
+ end
10
+
11
+ attr_accessor :tools, :comparison_branch, :annotator, :executor
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,36 @@
1
+ module QuietQuality
2
+ module Config
3
+ class ParsedOptions
4
+ def initialize
5
+ @tools = []
6
+ @tool_options = {}
7
+ @global_options = {}
8
+ @helping = false
9
+ end
10
+
11
+ attr_accessor :tools
12
+ attr_writer :helping
13
+
14
+ def helping?
15
+ @helping
16
+ end
17
+
18
+ def set_global_option(name, value)
19
+ @global_options[name.to_sym] = value
20
+ end
21
+
22
+ def global_option(name)
23
+ @global_options.fetch(name.to_sym, nil)
24
+ end
25
+
26
+ def set_tool_option(tool, name, value)
27
+ @tool_options[tool.to_sym] ||= {}
28
+ @tool_options[tool.to_sym][name.to_sym] = value
29
+ end
30
+
31
+ def tool_option(tool, name)
32
+ @tool_options.dig(tool.to_sym, name.to_sym)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,134 @@
1
+ module QuietQuality
2
+ module Config
3
+ class Parser
4
+ InvalidConfig = Class.new(Config::Error)
5
+
6
+ def initialize(path)
7
+ @path = path
8
+ end
9
+
10
+ def parsed_options
11
+ @_parsed_options ||= ParsedOptions.new.tap do |opts|
12
+ store_default_tools(opts)
13
+ store_global_options(opts)
14
+ store_tool_options(opts)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :path
21
+
22
+ def text
23
+ @_text ||= File.read(path)
24
+ end
25
+
26
+ def data
27
+ @_data ||= YAML.safe_load(text, symbolize_names: true)
28
+ end
29
+
30
+ def store_default_tools(opts)
31
+ tool_names = data.fetch(:default_tools, [])
32
+ invalid!("default_tools must be an array") unless tool_names.is_a?(Array)
33
+ tool_names.each do |name|
34
+ invalid!("each default tool must be a string") unless name.is_a?(String)
35
+ invalid!("unrecognized tool name '#{name}'") unless valid_tool?(name)
36
+ end
37
+ opts.tools = tool_names.map(&:to_sym)
38
+ end
39
+
40
+ def store_global_options(opts)
41
+ read_global_option(opts, :executor, as: :symbol, validate_from: Executors::AVAILABLE)
42
+ read_global_option(opts, :annotator, as: :symbol, validate_from: Annotators::ANNOTATOR_TYPES)
43
+ read_global_option(opts, :comparison_branch, as: :string)
44
+ read_global_option(opts, :changed_files, as: :boolean)
45
+ read_global_option(opts, :filter_messages, as: :boolean)
46
+ end
47
+
48
+ def store_tool_options(opts)
49
+ Tools::AVAILABLE.keys.each do |tool_name|
50
+ store_tool_options_for(opts, tool_name)
51
+ end
52
+ end
53
+
54
+ def store_tool_options_for(opts, tool_name)
55
+ entries = data.fetch(tool_name, nil)
56
+ return if entries.nil?
57
+ read_tool_option(opts, tool_name, :filter_messages, as: :boolean)
58
+ read_tool_option(opts, tool_name, :changed_files, as: :boolean)
59
+ end
60
+
61
+ def invalid!(message)
62
+ fail(InvalidConfig, message)
63
+ end
64
+
65
+ def valid_tool?(name)
66
+ Tools::AVAILABLE.key?(name.to_sym)
67
+ end
68
+
69
+ def valid_boolean?(value)
70
+ [true, false].include?(value)
71
+ end
72
+
73
+ def read_global_option(opts, name, as:, validate_from: nil)
74
+ parsed_value = data.fetch(name.to_sym, nil)
75
+ return if parsed_value.nil?
76
+
77
+ validate_value(name, parsed_value, as: as, from: validate_from)
78
+ coerced_value = coerce_value(parsed_value, as: as)
79
+ opts.set_global_option(name, coerced_value)
80
+ end
81
+
82
+ def read_tool_option(opts, tool, name, as:)
83
+ parsed_value = data.dig(tool.to_sym, name.to_sym)
84
+ return if parsed_value.nil?
85
+
86
+ validate_value("#{tool}.#{name}", parsed_value, as: as)
87
+ coerced_value = coerce_value(parsed_value, as: as)
88
+ opts.set_tool_option(tool, name, coerced_value)
89
+ end
90
+
91
+ def validate_value(name, value, as:, from: nil)
92
+ case as
93
+ when :boolean then validate_boolean(name, value)
94
+ when :symbol then validate_symbol(name, value, from: from)
95
+ when :string then validate_string(name, value)
96
+ else
97
+ fail ArgumentError, "validate_value does not handle type #{as}"
98
+ end
99
+ end
100
+
101
+ def validate_boolean(name, value)
102
+ return if valid_boolean?(value)
103
+ invalid!("option #{name} must be either true or false")
104
+ end
105
+
106
+ def validate_symbol(name, value, from: nil)
107
+ unless value.is_a?(String) || value.is_a?(Symbol)
108
+ invalid!("option #{name} must be a string or symbol")
109
+ end
110
+
111
+ unless from.nil? || from.include?(value.to_sym)
112
+ allowed_list = from.respond_to?(:keys) ? from.keys : from
113
+ allowed_string = allowed_list.map(&:to_s).join(", ")
114
+ invalid!("option #{name} must be one of the allowed values: #{allowed_string}")
115
+ end
116
+ end
117
+
118
+ def validate_string(name, value)
119
+ invalid!("option #{name} must be a string") unless value.is_a?(String)
120
+ invalid!("option #{name} must not be empty") if value.empty?
121
+ end
122
+
123
+ def coerce_value(value, as:)
124
+ case as
125
+ when :boolean then !!value
126
+ when :string then value.to_s
127
+ when :symbol then value.to_sym
128
+ else
129
+ fail ArgumentError, "coerce_value does not handle type #{as}"
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,34 @@
1
+ module QuietQuality
2
+ module Config
3
+ class ToolOptions
4
+ def initialize(tool, limit_targets: true, filter_messages: true)
5
+ @tool_name = tool.to_sym
6
+ @limit_targets = limit_targets
7
+ @filter_messages = filter_messages
8
+ end
9
+
10
+ attr_reader :tool_name
11
+ attr_writer :limit_targets, :filter_messages
12
+
13
+ def limit_targets?
14
+ @limit_targets
15
+ end
16
+
17
+ def filter_messages?
18
+ @filter_messages
19
+ end
20
+
21
+ def tool_namespace
22
+ Tools::AVAILABLE.fetch(tool_name)
23
+ end
24
+
25
+ def runner_class
26
+ tool_namespace::Runner
27
+ end
28
+
29
+ def parser_class
30
+ tool_namespace::Parser
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,8 @@
1
+ module QuietQuality
2
+ module Config
3
+ Error = Class.new(QuietQuality::Error)
4
+ end
5
+ end
6
+
7
+ glob = File.expand_path("../config/*.rb", __FILE__)
8
+ Dir.glob(glob).sort.each { |f| require f }
@@ -0,0 +1,45 @@
1
+ module QuietQuality
2
+ module Tools
3
+ module Brakeman
4
+ class Parser
5
+ def initialize(text)
6
+ @text = text
7
+ end
8
+
9
+ def messages
10
+ return @_messages if defined?(@_messages)
11
+ check_errors!
12
+ messages = warnings.map { |w| message_for(w) }
13
+ @_messages = Messages.new(messages)
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :text
19
+
20
+ def data
21
+ @_data ||= JSON.parse(text, symbolize_names: true)
22
+ end
23
+
24
+ def check_errors!
25
+ errors = data[:errors]
26
+ return if errors.nil? || errors.empty?
27
+ fail(ParsingError, "Found #{errors.length} errors in brakeman output")
28
+ end
29
+
30
+ def warnings
31
+ data[:warnings] || []
32
+ end
33
+
34
+ def message_for(warning)
35
+ path = warning.fetch(:file)
36
+ body = warning.fetch(:message)
37
+ line = warning.fetch(:line)
38
+ level = warning.fetch(:confidence, nil)
39
+ rule = warning.fetch(:warning_type)
40
+ Message.new(path: path, body: body, start_line: line, level: level, rule: rule)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,36 @@
1
+ module QuietQuality
2
+ module Tools
3
+ module Brakeman
4
+ class Runner
5
+ # These are specified in constants at the top of brakeman.rb:
6
+ # https://github.com/presidentbeef/brakeman/blob/main/lib/brakeman.rb#L6-L25
7
+ KNOWN_EXIT_STATUSES = [3, 4, 5, 6, 7, 8].to_set
8
+
9
+ def initialize(changed_files: nil)
10
+ @changed_files = changed_files
11
+ end
12
+
13
+ def invoke!
14
+ @_outcome ||= performed_outcome
15
+ end
16
+
17
+ private
18
+
19
+ def command
20
+ ["brakeman", "-f", "json"]
21
+ end
22
+
23
+ def performed_outcome
24
+ out, err, stat = Open3.capture3(*command)
25
+ if stat.success?
26
+ Outcome.new(tool: :brakeman, output: out, logging: err)
27
+ elsif KNOWN_EXIT_STATUSES.include?(stat.exitstatus)
28
+ Outcome.new(tool: :brakeman, output: out, logging: err, failure: true)
29
+ else
30
+ fail(ExecutionError, "Execution of brakeman failed with #{stat.exitstatus}")
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,13 @@
1
+ require_relative "./rubocop"
2
+
3
+ module QuietQuality
4
+ module Tools
5
+ module Brakeman
6
+ ExecutionError = Class.new(Tools::Error)
7
+ ParsingError = Class.new(Tools::Error)
8
+ end
9
+ end
10
+ end
11
+
12
+ glob = File.expand_path("../brakeman/*.rb", __FILE__)
13
+ Dir.glob(glob).sort.each { |f| require f }
@@ -0,0 +1,45 @@
1
+ module QuietQuality
2
+ module Tools
3
+ module HamlLint
4
+ class Parser
5
+ def initialize(text)
6
+ @text = text
7
+ end
8
+
9
+ def messages
10
+ return @_messages if defined?(@_messages)
11
+ messages = content
12
+ .fetch(:files)
13
+ .map { |f| messages_for_file(f) }
14
+ .flatten
15
+ @_messages = Messages.new(messages)
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :text
21
+
22
+ def content
23
+ @_content ||= JSON.parse(text, symbolize_names: true)
24
+ end
25
+
26
+ def messages_for_file(file_details)
27
+ path = file_details.fetch(:path)
28
+ file_details.fetch(:offenses).map do |offense|
29
+ message_for_offense(path, offense)
30
+ end
31
+ end
32
+
33
+ def message_for_offense(path, offense)
34
+ Message.new(
35
+ path: path,
36
+ body: offense.fetch(:message),
37
+ start_line: offense.dig(:location, :line),
38
+ level: offense.fetch(:severity, nil),
39
+ rule: offense.fetch(:linter_name, nil)
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,63 @@
1
+ module QuietQuality
2
+ module Tools
3
+ module HamlLint
4
+ class Runner
5
+ MAX_FILES = 100
6
+ NO_FILES_OUTPUT = %({"files": []})
7
+
8
+ # haml-lint uses the `sysexits` gem, and exits with Sysexits::EX_DATAERR for the
9
+ # failures case here in lib/haml_lint/cli.rb. That's mapped to status 65 - other
10
+ # statuses have other failure meanings, which we don't want to interpret as "problems
11
+ # encountered"
12
+ FAILURE_STATUS = 65
13
+
14
+ def initialize(changed_files: nil)
15
+ @changed_files = changed_files
16
+ end
17
+
18
+ def invoke!
19
+ @_outcome ||= skip_execution? ? skipped_outcome : performed_outcome
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :changed_files
25
+
26
+ def skip_execution?
27
+ changed_files && relevant_files.empty?
28
+ end
29
+
30
+ def relevant_files
31
+ return nil if changed_files.nil?
32
+ changed_files.paths.select { |path| path.end_with?(".haml") }
33
+ end
34
+
35
+ def target_files
36
+ return [] if changed_files.nil?
37
+ return [] if relevant_files.length > MAX_FILES
38
+ relevant_files
39
+ end
40
+
41
+ def command
42
+ return nil if skip_execution?
43
+ ["haml-lint", "--reporter", "json"] + target_files.sort
44
+ end
45
+
46
+ def skipped_outcome
47
+ Outcome.new(tool: :haml_lint, output: NO_FILES_OUTPUT)
48
+ end
49
+
50
+ def performed_outcome
51
+ out, err, stat = Open3.capture3(*command)
52
+ if stat.success?
53
+ Outcome.new(tool: :haml_lint, output: out, logging: err)
54
+ elsif stat.exitstatus == FAILURE_STATUS
55
+ Outcome.new(tool: :haml_lint, output: out, logging: err, failure: true)
56
+ else
57
+ fail(ExecutionError, "Execution of haml-lint failed with #{stat.exitstatus}")
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,11 @@
1
+ module QuietQuality
2
+ module Tools
3
+ module HamlLint
4
+ ExecutionError = Class.new(Tools::Error)
5
+ ParsingError = Class.new(Tools::Error)
6
+ end
7
+ end
8
+ end
9
+
10
+ glob = File.expand_path("../haml_lint/*.rb", __FILE__)
11
+ Dir.glob(glob).sort.each { |f| require f }
@@ -15,7 +15,7 @@ module QuietQuality
15
15
 
16
16
  private
17
17
 
18
- attr_reader :changed_files, :error_stream
18
+ attr_reader :changed_files
19
19
 
20
20
  def skip_execution?
21
21
  changed_files && relevant_files.empty?
@@ -13,6 +13,8 @@ Dir.glob(glob).sort.each { |f| require f }
13
13
  module QuietQuality
14
14
  module Tools
15
15
  AVAILABLE = {
16
+ brakeman: Brakeman,
17
+ haml_lint: HamlLint,
16
18
  rspec: Rspec,
17
19
  rubocop: Rubocop,
18
20
  standardrb: Standardrb
@@ -1,3 +1,3 @@
1
1
  module QuietQuality
2
- VERSION = "0.1.0"
2
+ VERSION = "1.0.0"
3
3
  end
data/lib/quiet_quality.rb CHANGED
@@ -2,6 +2,7 @@ require "git"
2
2
  require "git_diff_parser"
3
3
  require "json"
4
4
  require "yaml"
5
+ require "pathname"
5
6
 
6
7
  # 'set' doesn't need requiring after ruby 3.2, but it won't hurt anything.
7
8
  # And we're compatible back to 2.6
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quiet_quality
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Mueller
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-05-10 00:00:00.000000000 Z
11
+ date: 2023-05-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: git
@@ -150,6 +150,7 @@ files:
150
150
  - ".github/workflows/linters.yml"
151
151
  - ".github/workflows/rspec.yml"
152
152
  - ".gitignore"
153
+ - ".quiet_quality.yml"
153
154
  - ".rspec"
154
155
  - ".rubocop.yml"
155
156
  - Gemfile
@@ -163,9 +164,15 @@ files:
163
164
  - lib/quiet_quality/changed_file.rb
164
165
  - lib/quiet_quality/changed_files.rb
165
166
  - lib/quiet_quality/cli.rb
166
- - lib/quiet_quality/cli/option_parser.rb
167
- - lib/quiet_quality/cli/options.rb
168
- - lib/quiet_quality/cli/options_builder.rb
167
+ - lib/quiet_quality/cli/arg_parser.rb
168
+ - lib/quiet_quality/cli/entrypoint.rb
169
+ - lib/quiet_quality/config.rb
170
+ - lib/quiet_quality/config/builder.rb
171
+ - lib/quiet_quality/config/finder.rb
172
+ - lib/quiet_quality/config/options.rb
173
+ - lib/quiet_quality/config/parsed_options.rb
174
+ - lib/quiet_quality/config/parser.rb
175
+ - lib/quiet_quality/config/tool_options.rb
169
176
  - lib/quiet_quality/executors.rb
170
177
  - lib/quiet_quality/executors/base_executor.rb
171
178
  - lib/quiet_quality/executors/concurrent_executor.rb
@@ -174,8 +181,13 @@ files:
174
181
  - lib/quiet_quality/message.rb
175
182
  - lib/quiet_quality/message_filter.rb
176
183
  - lib/quiet_quality/messages.rb
177
- - lib/quiet_quality/tool_options.rb
178
184
  - lib/quiet_quality/tools.rb
185
+ - lib/quiet_quality/tools/brakeman.rb
186
+ - lib/quiet_quality/tools/brakeman/parser.rb
187
+ - lib/quiet_quality/tools/brakeman/runner.rb
188
+ - lib/quiet_quality/tools/haml_lint.rb
189
+ - lib/quiet_quality/tools/haml_lint/parser.rb
190
+ - lib/quiet_quality/tools/haml_lint/runner.rb
179
191
  - lib/quiet_quality/tools/outcome.rb
180
192
  - lib/quiet_quality/tools/rspec.rb
181
193
  - lib/quiet_quality/tools/rspec/parser.rb
@@ -1,103 +0,0 @@
1
- require "optparse"
2
-
3
- module QuietQuality
4
- module Cli
5
- class OptionParser
6
- attr_reader :options, :tool_options, :output
7
-
8
- def initialize(args)
9
- @args = args
10
- @options = {
11
- executor: :concurrent
12
- }
13
- @tool_options = {}
14
- @output = nil
15
- end
16
-
17
- def parse!
18
- parser.parse!(@args)
19
- [positional, options, tool_options]
20
- end
21
-
22
- def positional
23
- @args
24
- end
25
-
26
- private
27
-
28
- def parser
29
- ::OptionParser.new do |parser|
30
- setup_banner(parser)
31
- setup_help_output(parser)
32
- setup_executor_options(parser)
33
- setup_annotation_options(parser)
34
- setup_file_target_options(parser)
35
- setup_filter_messages_options(parser)
36
- end
37
- end
38
-
39
- def setup_banner(parser)
40
- parser.banner = "Usage: qq [TOOLS] [GLOBAL_OPTIONS] [TOOL_OPTIONS]"
41
- end
42
-
43
- def setup_help_output(parser)
44
- parser.on("-h", "--help", "Prints this help") do
45
- @output = parser.to_s
46
- @options[:exit_immediately] = true
47
- end
48
- end
49
-
50
- def setup_executor_options(parser)
51
- parser.on("-E", "--executor EXECUTOR", "Which executor to use") do |name|
52
- fail(UsageError, "Executor not recognized: #{name}") unless Executors::AVAILABLE.include?(name.to_sym)
53
- @options[:executor] = name.to_sym
54
- end
55
- end
56
-
57
- def setup_annotation_options(parser)
58
- parser.on("-A", "--annotate ANNOTATOR", "Annotate with this annotator") do |name|
59
- fail(UsageError, "Annotator not recognized: #{name}") unless Annotators::ANNOTATOR_TYPES.include?(name.to_sym)
60
- @options[:annotator] = name.to_sym
61
- end
62
-
63
- # shortcut option
64
- parser.on("-G", "--annotate-github-stdout", "Annotate with GitHub Workflow commands") do
65
- @options[:annotator] = :github_stdout
66
- end
67
- end
68
-
69
- def read_tool_or_global_option(name, tool, value)
70
- if tool
71
- @tool_options[tool.to_sym] ||= {}
72
- @tool_options[tool.to_sym][name] = value
73
- else
74
- @options[name] = value
75
- end
76
- end
77
-
78
- def setup_file_target_options(parser)
79
- parser.on("-a", "--all-files [tool]", "Use the tool(s) on all files") do |tool|
80
- read_tool_or_global_option(:all_files, tool, true)
81
- end
82
-
83
- parser.on("-c", "--changed-files [tool]", "Use the tool(s) only on changed files") do |tool|
84
- read_tool_or_global_option(:all_files, tool, false)
85
- end
86
-
87
- parser.on("-B", "--comparison-branch BRANCH", "Specify the branch to compare against") do |branch|
88
- @options[:comparison_branch] = branch
89
- end
90
- end
91
-
92
- def setup_filter_messages_options(parser)
93
- parser.on("-f", "--filter-messages [tool]", "Filter messages from tool(s) based on changed lines") do |tool|
94
- read_tool_or_global_option(:filter_messages, tool, true)
95
- end
96
-
97
- parser.on("-u", "--unfiltered [tool]", "Don't filter messages from tool(s)") do |tool|
98
- read_tool_or_global_option(:filter_messages, tool, false)
99
- end
100
- end
101
- end
102
- end
103
- end
@@ -1,27 +0,0 @@
1
- module QuietQuality
2
- module Cli
3
- class Options
4
- def initialize
5
- @annotator = nil
6
- @executor = Executors::ConcurrentExecutor
7
- @tools = nil
8
- @comparison_branch = nil
9
- end
10
-
11
- attr_reader :annotator, :executor
12
- attr_accessor :tools, :comparison_branch
13
-
14
- def annotator=(name)
15
- @annotator = Annotators::ANNOTATOR_TYPES.fetch(name.to_sym)
16
- rescue KeyError
17
- fail(UsageError, "Unrecognized annotator: #{name}")
18
- end
19
-
20
- def executor=(name)
21
- @executor = Executors::AVAILABLE.fetch(name.to_sym)
22
- rescue KeyError
23
- fail(UsageError, "Unrecognized executor: #{name}")
24
- end
25
- end
26
- end
27
- end
@@ -1,49 +0,0 @@
1
- module QuietQuality
2
- module Cli
3
- class OptionsBuilder
4
- def initialize(tool_names:, global_options:, tool_options:)
5
- @raw_tool_names = tool_names
6
- @raw_global_options = global_options
7
- @raw_tool_options = tool_options
8
- end
9
-
10
- def options
11
- return @_options if defined?(@_options)
12
- options = Options.new
13
- set_unless_nil(options, :annotator, @raw_global_options[:annotator])
14
- set_unless_nil(options, :executor, @raw_global_options[:executor])
15
- set_unless_nil(options, :comparison_branch, @raw_global_options[:comparison_branch])
16
- options.tools = tool_names.map { |tool_name| tool_options_for(tool_name) }
17
- @_options = options
18
- end
19
-
20
- private
21
-
22
- def set_unless_nil(object, method, value)
23
- return if value.nil?
24
- object.send("#{method}=", value)
25
- end
26
-
27
- def tool_options_for(tool_name)
28
- raw_tool_opts = @raw_tool_options.fetch(tool_name.to_sym, {})
29
- ToolOptions.new(tool_name).tap do |tool_options|
30
- set_unless_nil(tool_options, :limit_targets, @raw_global_options[:limit_targets])
31
- set_unless_nil(tool_options, :limit_targets, raw_tool_opts[:limit_targets])
32
-
33
- set_unless_nil(tool_options, :filter_messages, @raw_global_options[:filter_messages])
34
- set_unless_nil(tool_options, :filter_messages, raw_tool_opts[:filter_messages])
35
- end
36
- end
37
-
38
- def tool_names
39
- names = @raw_tool_names.empty? ? Tools::AVAILABLE.keys : @raw_tool_names
40
- names.map(&:to_sym).tap do |names|
41
- unexpected_names = names - Tools::AVAILABLE.keys
42
- if unexpected_names.any?
43
- fail(UsageError, "Tool(s) not recognized: #{unexpected_names.join(", ")}")
44
- end
45
- end
46
- end
47
- end
48
- end
49
- end
@@ -1,32 +0,0 @@
1
- module QuietQuality
2
- class ToolOptions
3
- def initialize(tool, limit_targets: true, filter_messages: true)
4
- @tool_name = tool.to_sym
5
- @limit_targets = limit_targets
6
- @filter_messages = filter_messages
7
- end
8
-
9
- attr_reader :tool_name
10
- attr_writer :limit_targets, :filter_messages
11
-
12
- def limit_targets?
13
- @limit_targets
14
- end
15
-
16
- def filter_messages?
17
- @filter_messages
18
- end
19
-
20
- def tool_namespace
21
- Tools::AVAILABLE.fetch(tool_name)
22
- end
23
-
24
- def runner_class
25
- tool_namespace::Runner
26
- end
27
-
28
- def parser_class
29
- tool_namespace::Parser
30
- end
31
- end
32
- end