quality_gate 0.2.1 → 0.3.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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +292 -21
  4. data/docs/codex.md +37 -9
  5. data/docs/releasing.md +16 -4
  6. data/lib/generators/quality_gate/install/install_generator.rb +18 -3
  7. data/lib/generators/quality_gate/install/templates/agents_section.md.tt +13 -2
  8. data/lib/generators/quality_gate/install/templates/codex_fast.rb.tt +36 -0
  9. data/lib/generators/quality_gate/install/templates/codex_hooks.json.tt +1 -0
  10. data/lib/generators/quality_gate/install/templates/codex_verify_stop.rb.tt +28 -0
  11. data/lib/generators/quality_gate/install/templates/quality_gate.yml.tt +6 -0
  12. data/lib/generators/quality_gate/install/templates/quality_gate_fast.rb.tt +16 -3
  13. data/lib/generators/quality_gate/install/templates/quality_gate_workflow.yml.tt +28 -0
  14. data/lib/generators/quality_gate/install/templates/rails_quality_gate.yml.tt +45 -0
  15. data/lib/generators/quality_gate/install/templates/ruby_agents_section.md.tt +13 -2
  16. data/lib/generators/quality_gate/install/templates/ruby_quality_gate.yml.tt +5 -0
  17. data/lib/generators/quality_gate/install/templates/simplecov.rb.tt +1 -0
  18. data/lib/quality_gate/adapters/bundler_audit.rb +28 -3
  19. data/lib/quality_gate/adapters/database_consistency.rb +51 -0
  20. data/lib/quality_gate/adapters/database_consistency_report.rb +61 -0
  21. data/lib/quality_gate/adapters/database_consistency_report_item.rb +122 -0
  22. data/lib/quality_gate/adapters/debride.rb +67 -0
  23. data/lib/quality_gate/adapters/debride_report.rb +80 -0
  24. data/lib/quality_gate/adapters/herb.rb +303 -0
  25. data/lib/quality_gate/adapters/reek.rb +21 -2
  26. data/lib/quality_gate/adapters/rubocop.rb +23 -9
  27. data/lib/quality_gate/adapters/rubycritic.rb +74 -0
  28. data/lib/quality_gate/adapters/rubycritic_report.rb +112 -0
  29. data/lib/quality_gate/adapters/test_suite.rb +12 -1
  30. data/lib/quality_gate/baseline.rb +269 -0
  31. data/lib/quality_gate/baseline_run.rb +80 -0
  32. data/lib/quality_gate/cli.rb +129 -6
  33. data/lib/quality_gate/codex_fast_hook.rb +163 -0
  34. data/lib/quality_gate/codex_patch_files.rb +95 -0
  35. data/lib/quality_gate/codex_stop_hook.rb +102 -0
  36. data/lib/quality_gate/config.rb +20 -6
  37. data/lib/quality_gate/database_consistency_runner.rb +124 -0
  38. data/lib/quality_gate/doctor.rb +217 -0
  39. data/lib/quality_gate/doctor_bounded_file.rb +74 -0
  40. data/lib/quality_gate/doctor_command.rb +130 -0
  41. data/lib/quality_gate/doctor_coverage.rb +113 -0
  42. data/lib/quality_gate/doctor_git.rb +121 -0
  43. data/lib/quality_gate/doctor_hooks.rb +99 -0
  44. data/lib/quality_gate/doctor_launchers.rb +300 -0
  45. data/lib/quality_gate/doctor_path_lookup.rb +52 -0
  46. data/lib/quality_gate/doctor_report.rb +38 -0
  47. data/lib/quality_gate/init_command.rb +3 -1
  48. data/lib/quality_gate/installation.rb +72 -96
  49. data/lib/quality_gate/installer.rb +3 -0
  50. data/lib/quality_gate/reporters/doctor.rb +57 -0
  51. data/lib/quality_gate/reporters/json.rb +18 -1
  52. data/lib/quality_gate/reporters/markdown.rb +11 -1
  53. data/lib/quality_gate/reporters/text.rb +14 -6
  54. data/lib/quality_gate/ruby_profile.rb +121 -2
  55. data/lib/quality_gate/runner.rb +15 -17
  56. data/lib/quality_gate/version.rb +1 -1
  57. data/lib/quality_gate.rb +15 -0
  58. data/sig/quality_gate.rbs +117 -2
  59. metadata +44 -1
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QualityGate
4
+ module Adapters
5
+ # Validates one database_consistency report before mapping it to a finding.
6
+ class DatabaseConsistencyReportItem
7
+ FIELDS = %w[
8
+ checker_name table_or_model_name column_or_attribute_name status error_slug error_message
9
+ ].freeze
10
+ LOCATION = /\A(.+):([0-9]+)\z/
11
+ SEVERITIES = { "fail" => :error, "warning" => :warning }.freeze
12
+ private_constant :FIELDS, :LOCATION, :SEVERITIES
13
+
14
+ def initialize(report)
15
+ @report = report
16
+ end
17
+
18
+ def finding
19
+ validate!
20
+ return if @report.fetch("status") == "ok"
21
+
22
+ Finding.new(**attributes)
23
+ end
24
+
25
+ private
26
+
27
+ def validate!
28
+ invalid!("report must be an object") unless @report.is_a?(Hash)
29
+ validate_fields!
30
+ validate_values!
31
+ end
32
+
33
+ def validate_fields!
34
+ FIELDS.each { invalid!("report must include #{_1}") unless @report.key?(_1) }
35
+ end
36
+
37
+ def validate_values!
38
+ validate_names!
39
+ validate_status!
40
+ validate_message!
41
+ validate_location!
42
+ end
43
+
44
+ def validate_names!
45
+ string!(@report.fetch("checker_name"), "checker_name")
46
+ nullable_string!(@report.fetch("table_or_model_name"), "table_or_model_name")
47
+ nullable_string!(@report.fetch("column_or_attribute_name"), "column_or_attribute_name")
48
+ end
49
+
50
+ def validate_status!
51
+ status = @report.fetch("status")
52
+ return if status == "ok" || SEVERITIES.key?(status)
53
+
54
+ invalid!("status must be ok, fail, or warning")
55
+ end
56
+
57
+ def validate_message!
58
+ nullable_string!(@report.fetch("error_slug"), "error_slug")
59
+ nullable_string!(@report.fetch("error_message"), "error_message")
60
+ return if @report.fetch("status") == "ok" || usable_message?
61
+
62
+ invalid!("finding requires a non-empty error_message or error_slug")
63
+ end
64
+
65
+ def usable_message?
66
+ %w[error_message error_slug].any? { !@report.fetch(_1).to_s.strip.empty? }
67
+ end
68
+
69
+ def validate_location!
70
+ value = @report.fetch("source_location", nil)
71
+ return if value.nil?
72
+ return if value.is_a?(String) && (match = LOCATION.match(value)) && Integer(match[2], 10).positive?
73
+
74
+ invalid!("source_location must be nil or a path followed by a positive line number")
75
+ end
76
+
77
+ def attributes
78
+ file, line = location
79
+ {
80
+ tool: "database_consistency", file:, line:, rule: @report.fetch("checker_name"),
81
+ severity: SEVERITIES.fetch(@report.fetch("status")), message: message
82
+ }
83
+ end
84
+
85
+ def location
86
+ value = @report.fetch("source_location", nil)
87
+ return ["", 0] unless value
88
+
89
+ match = LOCATION.match(value)
90
+ [match[1], Integer(match[2], 10)]
91
+ end
92
+
93
+ def message
94
+ context = [@report.fetch("table_or_model_name"), @report.fetch("column_or_attribute_name")]
95
+ .compact.reject { _1.strip.empty? }
96
+ detail = error_detail
97
+ context.empty? ? detail : "#{context.join(" ")}: #{detail}"
98
+ end
99
+
100
+ def error_detail
101
+ message = @report.fetch("error_message")
102
+ return message unless message.nil? || message.strip.empty?
103
+
104
+ @report.fetch("error_slug").tr("_", " ").strip
105
+ end
106
+
107
+ def string!(value, field)
108
+ invalid!("#{field} must be a non-empty string") unless value.is_a?(String) && !value.strip.empty?
109
+ end
110
+
111
+ def nullable_string!(value, field)
112
+ invalid!("#{field} must be a string or nil") unless value.nil? || value.is_a?(String)
113
+ end
114
+
115
+ def invalid!(reason)
116
+ raise ParseError.new(tool: "database_consistency", reason: reason)
117
+ end
118
+ end
119
+
120
+ private_constant :DatabaseConsistencyReportItem
121
+ end
122
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "debride_report"
4
+
5
+ module QualityGate
6
+ module Adapters
7
+ # Runs Debride project-wide and reports possible unused methods as warnings.
8
+ class Debride < Adapter
9
+ DEFAULT_LAUNCHER = ["debride"].freeze
10
+ private_constant :DEFAULT_LAUNCHER
11
+
12
+ def name = "debride"
13
+
14
+ def command
15
+ launcher = config.fetch(:commands).fetch(:deep, {}).fetch(:debride, DEFAULT_LAUNCHER)
16
+ launcher.dup.concat(["--json", "."])
17
+ end
18
+
19
+ def call = run_safely(name)
20
+
21
+ def parse(stdout)
22
+ DebrideReport.new(stdout).findings
23
+ rescue JSON::ParserError, KeyError, TypeError, ArgumentError => e
24
+ raise ParseError.new(tool: name, reason: e.message)
25
+ end
26
+
27
+ private
28
+
29
+ def run_safely(tool)
30
+ stderr = +""
31
+ execute { stderr = _1 }
32
+ rescue StandardError => e
33
+ [failure_finding(tool, e, stderr)]
34
+ end
35
+
36
+ def execute
37
+ stdout, stderr, status = capture(validated_command, resolved_timeout(name))
38
+ yield stderr
39
+ validate_process!(status, stderr)
40
+ normalize(stdout)
41
+ end
42
+
43
+ def normalize(stdout)
44
+ findings = parse(stdout)
45
+ validate_findings!(findings, expected_tool: name)
46
+ findings
47
+ end
48
+
49
+ def validate_process!(status, stderr)
50
+ validate_successful_exit!(status)
51
+ validate_stderr!(stderr)
52
+ end
53
+
54
+ def validate_successful_exit!(status)
55
+ return if status&.exited? && status.exitstatus.zero?
56
+
57
+ raise ParseError.new(tool: name, reason: "process did not exit normally with status 0")
58
+ end
59
+
60
+ def validate_stderr!(stderr)
61
+ return if stderr.strip.empty?
62
+
63
+ raise ParseError.new(tool: name, reason: "process wrote diagnostics to stderr")
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module QualityGate
6
+ module Adapters
7
+ # Validates Debride's JSON report before converting candidates into findings.
8
+ class DebrideReport
9
+ LOCATION = /\A(.+):([1-9]\d*)(?:-([1-9]\d*))?\z/
10
+ private_constant :LOCATION
11
+
12
+ def initialize(json)
13
+ @report = JSON.parse(json)
14
+ end
15
+
16
+ def findings
17
+ validate_report!
18
+ @report.fetch("missing").flat_map { |scope, candidates| scope_findings(scope, candidates) }
19
+ end
20
+
21
+ private
22
+
23
+ def validate_report!
24
+ raise TypeError, "report must be a JSON object" unless @report.is_a?(Hash)
25
+ raise TypeError, "missing must be a JSON object" unless @report.fetch("missing").is_a?(Hash)
26
+ end
27
+
28
+ def scope_findings(scope, candidates)
29
+ require_string!(scope, "scope")
30
+ raise TypeError, "candidates must be an array" unless candidates.is_a?(Array)
31
+
32
+ candidates.map { |candidate| candidate_finding(scope, candidate) }
33
+ end
34
+
35
+ def candidate_finding(scope, candidate)
36
+ validate_candidate!(candidate)
37
+ method, location = candidate
38
+ file, line = parse_location(location)
39
+ build_finding(scope, method, file, line)
40
+ end
41
+
42
+ def validate_candidate!(candidate)
43
+ unless candidate.is_a?(Array) && candidate.length == 2
44
+ raise TypeError, "candidate must be a [name, location] pair"
45
+ end
46
+
47
+ require_string!(candidate.fetch(0), "candidate name")
48
+ require_string!(candidate.fetch(1), "candidate location")
49
+ end
50
+
51
+ def parse_location(location)
52
+ match = LOCATION.match(location)
53
+ raise ArgumentError, "invalid candidate location #{location.inspect}" unless match
54
+
55
+ first = Integer(match[2], 10)
56
+ validate_range!(first, match[3])
57
+ [match[1], first]
58
+ end
59
+
60
+ def validate_range!(first, last_text)
61
+ return unless last_text
62
+
63
+ raise ArgumentError, "candidate line range is reversed" if Integer(last_text, 10) < first
64
+ end
65
+
66
+ def build_finding(scope, method, file, line)
67
+ Finding.new(
68
+ tool: "debride", file:, line:, rule: "potentially_unused_method", severity: :warning,
69
+ message: "#{scope}: #{method} is potentially unused"
70
+ )
71
+ end
72
+
73
+ def require_string!(value, label)
74
+ raise TypeError, "#{label} must be a non-empty String" unless value.is_a?(String) && !value.empty?
75
+ end
76
+ end
77
+
78
+ private_constant :DebrideReport
79
+ end
80
+ end
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module QualityGate
6
+ module Adapters
7
+ class HerbReport
8
+ SEVERITIES = { "error" => :error, "warning" => :warning, "info" => :info, "hint" => :info }.freeze
9
+
10
+ def self.validate_status!(status, findings)
11
+ validate_normal_exit!(status)
12
+ validate_exit_findings!(status, findings)
13
+ end
14
+
15
+ def self.validate_normal_exit!(status)
16
+ return if status&.exited? && [0, 1].include?(status.exitstatus)
17
+
18
+ raise ParseError.new(tool: "herb", reason: "process did not exit normally with status 0 or 1")
19
+ end
20
+
21
+ def self.validate_exit_findings!(status, findings)
22
+ valid = status.exitstatus.zero? ? findings.none? { _1.severity == :error } : findings.any?
23
+ return if valid
24
+
25
+ raise ParseError.new(tool: "herb", reason: "exit status #{status.exitstatus} conflicts with the report")
26
+ end
27
+
28
+ def initialize(report)
29
+ @report = report
30
+ end
31
+
32
+ def parse
33
+ validate_envelope!
34
+ offenses = @report.fetch("offenses")
35
+ findings = build_findings(offenses)
36
+ HerbSummary.validate!(@report.fetch("summary"), offenses, @report.fetch("clean"))
37
+ findings
38
+ end
39
+
40
+ private
41
+
42
+ def validate_envelope!
43
+ raise TypeError, "report must be a JSON object" unless @report.is_a?(Hash)
44
+ raise TypeError, "completed must be true" unless @report.fetch("completed") == true
45
+ raise TypeError, "clean must be a Boolean" unless [true, false].include?(@report.fetch("clean"))
46
+ end
47
+
48
+ def build_findings(offenses)
49
+ raise TypeError, "offenses must be an array" unless offenses.is_a?(Array)
50
+
51
+ offenses.map { build_finding(_1) }
52
+ end
53
+
54
+ def build_finding(offense)
55
+ raise TypeError, "offense must be an object" unless offense.is_a?(Hash)
56
+
57
+ Finding.new(tool: "herb", **finding_attributes(offense))
58
+ end
59
+
60
+ def finding_attributes(offense)
61
+ offense_identity(offense).merge(offense_location(offense)).merge(offense_severity(offense))
62
+ end
63
+
64
+ def offense_identity(offense)
65
+ { file: required_string(offense.fetch("filename"), "filename"),
66
+ rule: required_string(offense.fetch("code"), "code"),
67
+ message: offense_message(offense.fetch("message")) }
68
+ end
69
+
70
+ def offense_location(offense)
71
+ location = offense.fetch("location")
72
+ raise TypeError, "location must be an object" unless location.is_a?(Hash)
73
+
74
+ start = location.fetch("start")
75
+ raise TypeError, "location start must be an object" unless start.is_a?(Hash)
76
+
77
+ { line: positive_line(start.fetch("line")) }
78
+ end
79
+
80
+ def offense_severity(offense)
81
+ { severity: SEVERITIES.fetch(offense.fetch("severity")) }
82
+ end
83
+
84
+ def required_string(value, field)
85
+ return value if value.is_a?(String) && !value.empty?
86
+
87
+ raise TypeError, "#{field} must be a non-empty String"
88
+ end
89
+
90
+ def positive_line(value)
91
+ return value if value.is_a?(Integer) && value.positive?
92
+
93
+ raise TypeError, "line must be a positive Integer"
94
+ end
95
+
96
+ def offense_message(value)
97
+ return value if value.is_a?(String)
98
+
99
+ raise TypeError, "message must be a String"
100
+ end
101
+ end
102
+ private_constant :HerbReport
103
+
104
+ class HerbSummary
105
+ def self.validate!(summary, offenses, clean)
106
+ validate_summary_object!(summary)
107
+ counts = offenses.map { _1.fetch("severity") }.tally
108
+ validate_error_counts!(summary, counts)
109
+ validate_other_counts!(summary, counts)
110
+ validate_clean!(counts, clean)
111
+ end
112
+
113
+ def self.validate_summary_object!(summary)
114
+ raise TypeError, "summary must be an object" unless summary.is_a?(Hash)
115
+ end
116
+
117
+ def self.validate_clean!(counts, clean)
118
+ actionable = counts.fetch("error", 0) + counts.fetch("warning", 0)
119
+ raise TypeError, "clean does not match offense summary" unless clean == actionable.zero?
120
+ end
121
+
122
+ def self.validate_error_counts!(summary, counts)
123
+ errors = counts.fetch("error", 0)
124
+ warnings = counts.fetch("warning", 0)
125
+ validate_count!(summary, "totalErrors", errors)
126
+ validate_count!(summary, "totalWarnings", warnings)
127
+ validate_count!(summary, "totalOffenses", errors + warnings)
128
+ end
129
+
130
+ def self.validate_other_counts!(summary, counts)
131
+ validate_optional_count!(summary, "totalInfo", counts.fetch("info", 0))
132
+ validate_optional_count!(summary, "totalHints", counts.fetch("hint", 0))
133
+ validate_optional_count!(summary, "totalNotReported", 0)
134
+ end
135
+
136
+ def self.validate_count!(summary, key, expected)
137
+ return if valid_count?(summary.fetch(key), expected)
138
+
139
+ raise TypeError, "summary #{key} does not match offenses"
140
+ end
141
+
142
+ def self.validate_optional_count!(summary, key, expected)
143
+ return unless summary.key?(key)
144
+
145
+ validate_count!(summary, key, expected)
146
+ end
147
+
148
+ def self.valid_count?(value, expected)
149
+ value.is_a?(Integer) && value >= 0 && value == expected
150
+ end
151
+ end
152
+ private_constant :HerbSummary
153
+
154
+ class HerbExcludedSelection
155
+ PREFIX = "⚠️ File "
156
+ SUFFIX = " is excluded by configuration patterns."
157
+ INSTRUCTIONS = " Use --force to lint it anyway."
158
+
159
+ def self.paths(stdout:, stderr:, status:, requested_paths:)
160
+ new(stdout:, stderr:, status:, requested_paths:).paths
161
+ end
162
+
163
+ def initialize(stdout:, stderr:, status:, requested_paths:)
164
+ @stdout = stdout
165
+ @stderr = stderr
166
+ @status = status
167
+ @requested_paths = requested_paths
168
+ end
169
+
170
+ def paths
171
+ return unless intentional_skip?
172
+
173
+ skipped = diagnostic_paths
174
+ return unless skipped.any? && skipped.uniq == skipped && skipped.all? { selected_files.include?(_1) }
175
+
176
+ skipped
177
+ end
178
+
179
+ private
180
+
181
+ def intentional_skip?
182
+ @stdout.empty? && @status&.exited? && @status.exitstatus.zero? && @stderr.end_with?("\n\n")
183
+ end
184
+
185
+ def diagnostic_paths
186
+ blocks = @stderr.split("\n\n", -1)
187
+ blocks[0...-1].map { excluded_path(_1) }
188
+ end
189
+
190
+ def excluded_path(block)
191
+ lines = block.lines(chomp: true)
192
+ return unless lines.length == 2 && lines.last == INSTRUCTIONS
193
+
194
+ first = lines.first
195
+ return unless first.start_with?(PREFIX) && first.end_with?(SUFFIX)
196
+
197
+ first.delete_prefix(PREFIX).delete_suffix(SUFFIX)
198
+ end
199
+
200
+ def selected_files
201
+ @requested_paths.select { File.file?(_1) && File.extname(_1).downcase == ".erb" }
202
+ end
203
+ end
204
+ private_constant :HerbExcludedSelection
205
+
206
+ # Runs the optional Herb CLI and validates its structured ERB lint report.
207
+ class Herb < Adapter
208
+ CLI_FLAGS = %w[--json --no-github --no-timing --log-level hint].freeze
209
+ private_constant :CLI_FLAGS
210
+
211
+ def name = "herb"
212
+
213
+ def command = command_for(resolved_paths)
214
+
215
+ def call
216
+ scan_selected_paths
217
+ rescue StandardError => e
218
+ [failure_finding(name, e, "")]
219
+ end
220
+
221
+ def parse(stdout)
222
+ report = JSON.parse(stdout)
223
+ HerbReport.new(report).parse
224
+ rescue JSON::ParserError, KeyError, TypeError, ArgumentError => e
225
+ raise ParseError.new(tool: name, reason: e.message)
226
+ end
227
+
228
+ private
229
+
230
+ def scan_selected_paths
231
+ paths = resolved_paths.dup
232
+ return [] if files.any? && paths.empty?
233
+
234
+ scan(paths)
235
+ end
236
+
237
+ def scan(paths)
238
+ deadline = monotonic_deadline(resolved_timeout(name))
239
+ scan_until_complete(paths, deadline)
240
+ end
241
+
242
+ def scan_until_complete(paths, deadline)
243
+ loop do
244
+ result = scan_iteration(paths, deadline)
245
+ return result.fetch(:findings) if result.key?(:findings)
246
+ return [] if (paths = result.fetch(:paths)).empty?
247
+ end
248
+ end
249
+
250
+ def scan_iteration(paths, deadline)
251
+ stdout, stderr, status = capture_scan(paths, deadline)
252
+ iteration_result(paths, stdout, stderr, status)
253
+ rescue StandardError => e
254
+ { findings: [failure_finding(name, e, stderr)] }
255
+ end
256
+
257
+ def iteration_result(paths, stdout, stderr, status)
258
+ excluded = excluded_paths(stdout, stderr, status, paths)
259
+ return { paths: paths - excluded } if excluded
260
+
261
+ { findings: findings_for(stdout, status) }
262
+ end
263
+
264
+ def capture_scan(paths, deadline)
265
+ remaining = remaining_before(deadline)
266
+ raise ParseError.new(tool: name, reason: "timeout during exclusion retry") unless remaining.positive?
267
+
268
+ capture(command_for(paths), remaining)
269
+ end
270
+
271
+ def excluded_paths(stdout, stderr, status, paths)
272
+ HerbExcludedSelection.paths(stdout:, stderr:, status:, requested_paths: paths)
273
+ end
274
+
275
+ def findings_for(stdout, status)
276
+ findings = parse(stdout)
277
+ validate_findings!(findings, expected_tool: name)
278
+ HerbReport.validate_status!(status, findings)
279
+ findings
280
+ end
281
+
282
+ def command_for(paths)
283
+ argv = configured_launcher + CLI_FLAGS
284
+ argv.concat(paths.empty? && files.empty? ? ["."] : paths)
285
+ end
286
+
287
+ def configured_launcher
288
+ config.fetch(:commands).fetch(:fast).fetch(:herb, ["herb-lint"])
289
+ end
290
+
291
+ def resolved_paths
292
+ files.filter_map { existing_herb_path(_1) }.freeze
293
+ end
294
+
295
+ def existing_herb_path(path)
296
+ return unless File.exist?(path)
297
+ return if File.file?(path) && File.extname(path).downcase != ".erb"
298
+
299
+ path.start_with?("-") ? File.join(".", path) : path
300
+ end
301
+ end
302
+ end
303
+ end
@@ -38,7 +38,8 @@ module QualityGate
38
38
  def command
39
39
  argv = ["reek", "--format", "json"]
40
40
  argv.concat(["--config", CONFIG_PATH]) unless host_config?
41
- argv.concat(resolved_paths_for_command)
41
+ paths = resolved_paths_for_command
42
+ argv.concat(paths.empty? && files.empty? ? ["."] : paths)
42
43
  end
43
44
 
44
45
  def parse(stdout)
@@ -52,8 +53,26 @@ module QualityGate
52
53
 
53
54
  private
54
55
 
56
+ def capture(*arguments, **options)
57
+ output = super(*arguments, **options)
58
+ validate_run!(output.last, output.fetch(1))
59
+ output
60
+ end
61
+
62
+ def validate_run!(status, stderr)
63
+ code = status.exitstatus
64
+ raise Error, "unsupported Reek exit status #{code || "unknown"}" unless [0, 2].include?(code)
65
+
66
+ raise Error, "source cannot be processed by Reek" if stderr.include?("cannot be processed by Reek")
67
+ end
68
+
55
69
  def existing_paths
56
- files.filter_map { |path| positional_path(path) if File.exist?(path) }.freeze
70
+ files.filter_map do |path|
71
+ next unless File.exist?(path)
72
+ next if File.file?(path) && File.extname(path).downcase == ".erb"
73
+
74
+ positional_path(path)
75
+ end.freeze
57
76
  end
58
77
 
59
78
  def positional_path(path) = path.start_with?("-") ? File.join(".", path) : path
@@ -5,8 +5,22 @@ require "pathname"
5
5
 
6
6
  module QualityGate
7
7
  module Adapters
8
+ module RuboCopProcessStatus
9
+ private
10
+
11
+ def capture(*arguments, **options)
12
+ output = super(*arguments, **options)
13
+ code = output.last.exitstatus
14
+ raise Error, "unsupported RuboCop exit status #{code || "unknown"}" unless [0, 1].include?(code)
15
+
16
+ output
17
+ end
18
+ end
19
+ private_constant :RuboCopProcessStatus
20
+
8
21
  # Runs RuboCop with project-aware configuration and normalizes its JSON report.
9
22
  class RuboCop < Adapter
23
+ include RuboCopProcessStatus
10
24
  SEVERITY_MAP = {
11
25
  "info" => :info,
12
26
  "refactor" => :info,
@@ -57,7 +71,9 @@ module QualityGate
57
71
  end
58
72
 
59
73
  def parse(stdout)
60
- report = parse_report(stdout)
74
+ report = JSON.parse(stdout)
75
+ raise TypeError, "report must be a JSON object" unless report.is_a?(Hash)
76
+
61
77
  parse_files(report.fetch("files"))
62
78
  rescue JSON::ParserError, KeyError, TypeError, ArgumentError => e
63
79
  raise ParseError.new(tool: name, reason: e.message)
@@ -66,7 +82,12 @@ module QualityGate
66
82
  private
67
83
 
68
84
  def existing_paths
69
- files.filter_map { |path| positional_path(path) if File.exist?(path) }.freeze
85
+ files.filter_map do |path|
86
+ next unless File.exist?(path)
87
+ next if File.file?(path) && File.extname(path).downcase == ".erb"
88
+
89
+ positional_path(path)
90
+ end.freeze
70
91
  end
71
92
 
72
93
  def positional_path(path) = path.start_with?("-") ? File.join(".", path) : path
@@ -96,13 +117,6 @@ module QualityGate
96
117
 
97
118
  def resolved_paths_for_command = @resolved_paths_for_call || existing_paths
98
119
 
99
- def parse_report(stdout)
100
- report = JSON.parse(stdout)
101
- raise TypeError, "report must be a JSON object" unless report.is_a?(Hash)
102
-
103
- report
104
- end
105
-
106
120
  def parse_files(entries)
107
121
  raise TypeError, "files must be an array" unless entries.is_a?(Array)
108
122