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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +292 -21
- data/docs/codex.md +37 -9
- data/docs/releasing.md +16 -4
- data/lib/generators/quality_gate/install/install_generator.rb +18 -3
- data/lib/generators/quality_gate/install/templates/agents_section.md.tt +13 -2
- data/lib/generators/quality_gate/install/templates/codex_fast.rb.tt +36 -0
- data/lib/generators/quality_gate/install/templates/codex_hooks.json.tt +1 -0
- data/lib/generators/quality_gate/install/templates/codex_verify_stop.rb.tt +28 -0
- data/lib/generators/quality_gate/install/templates/quality_gate.yml.tt +6 -0
- data/lib/generators/quality_gate/install/templates/quality_gate_fast.rb.tt +16 -3
- data/lib/generators/quality_gate/install/templates/quality_gate_workflow.yml.tt +28 -0
- data/lib/generators/quality_gate/install/templates/rails_quality_gate.yml.tt +45 -0
- data/lib/generators/quality_gate/install/templates/ruby_agents_section.md.tt +13 -2
- data/lib/generators/quality_gate/install/templates/ruby_quality_gate.yml.tt +5 -0
- data/lib/generators/quality_gate/install/templates/simplecov.rb.tt +1 -0
- data/lib/quality_gate/adapters/bundler_audit.rb +28 -3
- data/lib/quality_gate/adapters/database_consistency.rb +51 -0
- data/lib/quality_gate/adapters/database_consistency_report.rb +61 -0
- data/lib/quality_gate/adapters/database_consistency_report_item.rb +122 -0
- data/lib/quality_gate/adapters/debride.rb +67 -0
- data/lib/quality_gate/adapters/debride_report.rb +80 -0
- data/lib/quality_gate/adapters/herb.rb +303 -0
- data/lib/quality_gate/adapters/reek.rb +21 -2
- data/lib/quality_gate/adapters/rubocop.rb +23 -9
- data/lib/quality_gate/adapters/rubycritic.rb +74 -0
- data/lib/quality_gate/adapters/rubycritic_report.rb +112 -0
- data/lib/quality_gate/adapters/test_suite.rb +12 -1
- data/lib/quality_gate/baseline.rb +269 -0
- data/lib/quality_gate/baseline_run.rb +80 -0
- data/lib/quality_gate/cli.rb +129 -6
- data/lib/quality_gate/codex_fast_hook.rb +163 -0
- data/lib/quality_gate/codex_patch_files.rb +95 -0
- data/lib/quality_gate/codex_stop_hook.rb +102 -0
- data/lib/quality_gate/config.rb +20 -6
- data/lib/quality_gate/database_consistency_runner.rb +124 -0
- data/lib/quality_gate/doctor.rb +217 -0
- data/lib/quality_gate/doctor_bounded_file.rb +74 -0
- data/lib/quality_gate/doctor_command.rb +130 -0
- data/lib/quality_gate/doctor_coverage.rb +113 -0
- data/lib/quality_gate/doctor_git.rb +121 -0
- data/lib/quality_gate/doctor_hooks.rb +99 -0
- data/lib/quality_gate/doctor_launchers.rb +300 -0
- data/lib/quality_gate/doctor_path_lookup.rb +52 -0
- data/lib/quality_gate/doctor_report.rb +38 -0
- data/lib/quality_gate/init_command.rb +3 -1
- data/lib/quality_gate/installation.rb +72 -96
- data/lib/quality_gate/installer.rb +3 -0
- data/lib/quality_gate/reporters/doctor.rb +57 -0
- data/lib/quality_gate/reporters/json.rb +18 -1
- data/lib/quality_gate/reporters/markdown.rb +11 -1
- data/lib/quality_gate/reporters/text.rb +14 -6
- data/lib/quality_gate/ruby_profile.rb +121 -2
- data/lib/quality_gate/runner.rb +15 -17
- data/lib/quality_gate/version.rb +1 -1
- data/lib/quality_gate.rb +15 -0
- data/sig/quality_gate.rbs +117 -2
- 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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|