quality_gate 0.1.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 +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.txt +21 -0
- data/README.md +571 -0
- data/config/37signals.yml +26 -0
- data/config/cops.yml +37 -0
- data/config/reek.yml +6 -0
- data/config/rubocop.yml +60 -0
- data/config/ruby.yml +8 -0
- data/docs/codex.md +31 -0
- data/docs/dogfood-log.md +47 -0
- data/docs/incidents.md +18 -0
- data/docs/releasing.md +46 -0
- data/exe/quality_gate +6 -0
- data/lib/generators/quality_gate/install/install_generator.rb +66 -0
- data/lib/generators/quality_gate/install/templates/agents_section.md.tt +15 -0
- data/lib/generators/quality_gate/install/templates/bullet.rb.tt +10 -0
- data/lib/generators/quality_gate/install/templates/claude_settings.json.tt +29 -0
- data/lib/generators/quality_gate/install/templates/hook_log_filesystem.rb.tt +76 -0
- data/lib/generators/quality_gate/install/templates/quality_gate.yml.tt +42 -0
- data/lib/generators/quality_gate/install/templates/quality_gate_fast.rb.tt +248 -0
- data/lib/generators/quality_gate/install/templates/quality_gate_verify_stop.rb.tt +466 -0
- data/lib/generators/quality_gate/install/templates/rubocop.yml.tt +1 -0
- data/lib/generators/quality_gate/install/templates/ruby_agents_section.md.tt +15 -0
- data/lib/generators/quality_gate/install/templates/ruby_quality_gate.yml.tt +40 -0
- data/lib/generators/quality_gate/install/templates/ruby_rubocop.yml.tt +1 -0
- data/lib/generators/quality_gate/install/templates/ruby_simplecov.rb.tt +11 -0
- data/lib/generators/quality_gate/install/templates/simplecov.rb.tt +10 -0
- data/lib/generators/quality_gate/install/templates/strong_migrations.rb.tt +10 -0
- data/lib/quality_gate/adapter.rb +328 -0
- data/lib/quality_gate/adapters/brakeman.rb +125 -0
- data/lib/quality_gate/adapters/bundler_audit.rb +235 -0
- data/lib/quality_gate/adapters/reek.rb +108 -0
- data/lib/quality_gate/adapters/rubocop.rb +142 -0
- data/lib/quality_gate/adapters/simplecov.rb +103 -0
- data/lib/quality_gate/adapters/test_suite.rb +81 -0
- data/lib/quality_gate/adapters/undercover.rb +357 -0
- data/lib/quality_gate/cli.rb +451 -0
- data/lib/quality_gate/config.rb +290 -0
- data/lib/quality_gate/exit_code.rb +14 -0
- data/lib/quality_gate/finding.rb +50 -0
- data/lib/quality_gate/hook_log.rb +131 -0
- data/lib/quality_gate/init_command.rb +90 -0
- data/lib/quality_gate/installation.rb +1577 -0
- data/lib/quality_gate/installer.rb +104 -0
- data/lib/quality_gate/railtie.rb +16 -0
- data/lib/quality_gate/reporters/field_sanitizer.rb +42 -0
- data/lib/quality_gate/reporters/json.rb +59 -0
- data/lib/quality_gate/reporters/text.rb +45 -0
- data/lib/quality_gate/rubocop.rb +34 -0
- data/lib/quality_gate/ruby_profile.rb +170 -0
- data/lib/quality_gate/runner.rb +150 -0
- data/lib/quality_gate/version.rb +5 -0
- data/lib/quality_gate.rb +27 -0
- data/lib/rubocop/cop/quality_gate/association_default_block_value.rb +46 -0
- data/lib/rubocop/cop/quality_gate/broadcast_in_controller.rb +64 -0
- data/lib/rubocop/cop/quality_gate/controller_instance_variables.rb +47 -0
- data/lib/rubocop/cop/quality_gate/prefer_after_save_commit.rb +84 -0
- data/lib/rubocop/cop/quality_gate/private_only_concern.rb +77 -0
- data/llm.txt +13 -0
- data/sig/quality_gate.rbs +226 -0
- metadata +260 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rubygems"
|
|
5
|
+
|
|
6
|
+
module QualityGate
|
|
7
|
+
module Adapters
|
|
8
|
+
# Runs bundler-audit and falls back to a usable local advisory database.
|
|
9
|
+
# rubocop:disable Metrics/ClassLength
|
|
10
|
+
class BundlerAudit < Adapter
|
|
11
|
+
class InvalidReport < StandardError; end
|
|
12
|
+
class UnavailableDatabase < StandardError; end
|
|
13
|
+
private_constant :InvalidReport, :UnavailableDatabase
|
|
14
|
+
|
|
15
|
+
LOCK_FILE = "Gemfile.lock".freeze # rubocop:disable Style/RedundantFreeze
|
|
16
|
+
FALLBACK_WARNING = "bundler_audit: advisory database update failed; using cached database"
|
|
17
|
+
private_constant :FALLBACK_WARNING
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
tool = name
|
|
21
|
+
argv = validated_command
|
|
22
|
+
timeout_seconds = resolved_timeout(tool)
|
|
23
|
+
deadline = monotonic_deadline(timeout_seconds)
|
|
24
|
+
updated_findings(tool, argv, deadline, timeout_seconds)
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
[failure_finding(tool, e, "")]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def name = "bundler_audit"
|
|
30
|
+
|
|
31
|
+
def command = %w[bundle-audit check --update --format json --quiet]
|
|
32
|
+
|
|
33
|
+
def parse(stdout)
|
|
34
|
+
report = parse_report(stdout)
|
|
35
|
+
results = report.fetch("results") { invalid_report!("report must include results") }
|
|
36
|
+
parse_results(results)
|
|
37
|
+
rescue JSON::ParserError, InvalidReport => e
|
|
38
|
+
raise ParseError.new(tool: name, reason: e.message)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def updated_findings(tool, argv, deadline, timeout_seconds)
|
|
44
|
+
stderr = +""
|
|
45
|
+
stdout, stderr, status = capture(argv, remaining_timeout(deadline, timeout_seconds))
|
|
46
|
+
findings = parse_and_validate(stdout, tool)
|
|
47
|
+
return findings if local_database?
|
|
48
|
+
|
|
49
|
+
unavailable_database_failure(tool, stderr)
|
|
50
|
+
rescue ParseError => e
|
|
51
|
+
return [failure_finding(tool, e, stderr)] if status&.success?
|
|
52
|
+
|
|
53
|
+
fallback_findings(tool, e, stderr, deadline, timeout_seconds)
|
|
54
|
+
rescue TimeoutError, SystemCallError, IOError, ThreadError => e
|
|
55
|
+
fallback_findings(tool, e, stderr, deadline, timeout_seconds)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse_and_validate(stdout, tool)
|
|
59
|
+
findings = parse(stdout)
|
|
60
|
+
validate_findings!(findings, expected_tool: tool)
|
|
61
|
+
findings
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def fallback_findings(tool, update_error, update_stderr, deadline, timeout_seconds)
|
|
65
|
+
return [failure_finding(tool, update_error, update_stderr)] unless local_database?
|
|
66
|
+
|
|
67
|
+
cached_findings(tool, deadline, timeout_seconds)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def cached_findings(tool, deadline, timeout_seconds)
|
|
71
|
+
cached_stderr = +""
|
|
72
|
+
stdout, cached_stderr, = capture(cached_command, remaining_timeout(deadline, timeout_seconds))
|
|
73
|
+
findings = parse_and_validate(stdout, tool)
|
|
74
|
+
emit_fallback_warning(tool, findings, cached_stderr)
|
|
75
|
+
rescue ParseError, TimeoutError, SystemCallError, IOError, ThreadError => e
|
|
76
|
+
[failure_finding(tool, e, cached_stderr)]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def cached_command = %w[bundle-audit check --no-update --format json --quiet]
|
|
80
|
+
|
|
81
|
+
def remaining_timeout(deadline, timeout_seconds)
|
|
82
|
+
remaining = remaining_before(deadline)
|
|
83
|
+
return remaining if remaining.positive?
|
|
84
|
+
|
|
85
|
+
raise TimeoutError, "timeout after #{timeout_seconds} seconds"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def unavailable_database_failure(tool, stderr)
|
|
89
|
+
error = UnavailableDatabase.new("no usable advisory database is available")
|
|
90
|
+
[failure_finding(tool, error, stderr)]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def emit_fallback_warning(tool, findings, cached_stderr)
|
|
94
|
+
diagnostic_io.puts FALLBACK_WARNING
|
|
95
|
+
findings
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
[failure_finding(tool, e, cached_stderr)]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parse_report(stdout)
|
|
101
|
+
report = JSON.parse(stdout)
|
|
102
|
+
invalid_report!("report must be a JSON object") unless report.is_a?(Hash)
|
|
103
|
+
|
|
104
|
+
report
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def parse_results(results)
|
|
108
|
+
invalid_report!("results must be an array") unless results.is_a?(Array)
|
|
109
|
+
|
|
110
|
+
results.map { build_finding(_1) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def build_finding(result)
|
|
114
|
+
invalid_report!("result must be an object") unless result.is_a?(Hash)
|
|
115
|
+
validate_result_type(result)
|
|
116
|
+
|
|
117
|
+
gem = object_field(result, "gem", owner: "result")
|
|
118
|
+
advisory = object_field(result, "advisory", owner: "result")
|
|
119
|
+
Finding.new(tool: name, **finding_attributes(gem, advisory))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def validate_result_type(result)
|
|
123
|
+
type = string_field(result, "type", owner: "result")
|
|
124
|
+
return if type == "unpatched_gem"
|
|
125
|
+
|
|
126
|
+
invalid_report!("unsupported result type #{type.inspect}")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def finding_attributes(gem, advisory)
|
|
130
|
+
gem_name = nonempty_string_field(gem, "name", owner: "gem")
|
|
131
|
+
installed_version = nonempty_string_field(gem, "version", owner: "gem")
|
|
132
|
+
patches = patched_versions(advisory)
|
|
133
|
+
|
|
134
|
+
{
|
|
135
|
+
file: LOCK_FILE,
|
|
136
|
+
line: 0,
|
|
137
|
+
rule: advisory_rule(advisory),
|
|
138
|
+
severity: :error,
|
|
139
|
+
message: advisory_message(gem_name, installed_version, patches.first)
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def advisory_rule(advisory)
|
|
144
|
+
cve = optional_identifier(advisory, "cve")
|
|
145
|
+
ghsa = optional_identifier(advisory, "ghsa")
|
|
146
|
+
advisory_id = nonempty_string_field(advisory, "id", owner: "advisory")
|
|
147
|
+
|
|
148
|
+
return prefixed_identifier(cve, "CVE-") if cve
|
|
149
|
+
return prefixed_identifier(ghsa, "GHSA-") if ghsa
|
|
150
|
+
|
|
151
|
+
advisory_id
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def advisory_message(gem_name, installed_version, patched_version)
|
|
155
|
+
subject = "#{gem_name} #{installed_version} is vulnerable"
|
|
156
|
+
return "#{subject}; update to #{patched_version}" if patched_version
|
|
157
|
+
|
|
158
|
+
"#{subject}; no patched version exists"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def patched_versions(advisory)
|
|
162
|
+
patches = advisory.fetch("patched_versions") do
|
|
163
|
+
invalid_report!("advisory must include patched_versions")
|
|
164
|
+
end
|
|
165
|
+
unless patches.is_a?(Array) && patches.all?(String)
|
|
166
|
+
invalid_report!("patched_versions must be an array of strings")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
patches
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def prefixed_identifier(identifier, prefix)
|
|
173
|
+
identifier.start_with?(prefix) ? identifier : "#{prefix}#{identifier}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def optional_identifier(advisory, field)
|
|
177
|
+
value = advisory.fetch(field, nil)
|
|
178
|
+
return if value.nil?
|
|
179
|
+
|
|
180
|
+
invalid_report!("#{field} must be nil or a non-empty String") unless value.is_a?(String) && !value.empty?
|
|
181
|
+
|
|
182
|
+
value
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def object_field(object, field, owner:)
|
|
186
|
+
value = object.fetch(field) { invalid_report!("#{owner} must include #{field}") }
|
|
187
|
+
invalid_report!("#{field} must be an object") unless value.is_a?(Hash)
|
|
188
|
+
|
|
189
|
+
value
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def string_field(object, field, owner:)
|
|
193
|
+
value = object.fetch(field) { invalid_report!("#{owner} must include #{field}") }
|
|
194
|
+
invalid_report!("#{field} must be a String") unless value.is_a?(String)
|
|
195
|
+
|
|
196
|
+
value
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def nonempty_string_field(object, field, owner:)
|
|
200
|
+
value = string_field(object, field, owner:)
|
|
201
|
+
invalid_report!("#{field} must not be empty") if value.empty?
|
|
202
|
+
|
|
203
|
+
value
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def local_database?
|
|
207
|
+
gems_path = File.join(advisory_database_path, "gems")
|
|
208
|
+
return false unless File.directory?(gems_path)
|
|
209
|
+
|
|
210
|
+
Dir.children(gems_path).any? { advisory_directory?(File.join(gems_path, _1)) }
|
|
211
|
+
rescue SystemCallError
|
|
212
|
+
false
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def advisory_directory?(path)
|
|
216
|
+
return false unless File.directory?(path)
|
|
217
|
+
|
|
218
|
+
Dir.children(path).any? do |entry|
|
|
219
|
+
File.extname(entry) == ".yml" && File.file?(File.join(path, entry))
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def advisory_database_path
|
|
224
|
+
ENV.fetch("BUNDLER_AUDIT_DB") do
|
|
225
|
+
File.join(Gem.user_home, ".local", "share", "ruby-advisory-db")
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def invalid_report!(reason)
|
|
230
|
+
raise InvalidReport, reason
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
# rubocop:enable Metrics/ClassLength
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module QualityGate
|
|
7
|
+
module Adapters
|
|
8
|
+
# Runs Reek with project-aware configuration and normalizes its JSON report.
|
|
9
|
+
class Reek < Adapter
|
|
10
|
+
CONFIG_PATH = File.expand_path("../../../config/reek.yml", __dir__).freeze
|
|
11
|
+
|
|
12
|
+
HOST_CONFIG_FILE = ".reek.yml"
|
|
13
|
+
PROJECT_ROOT_FILES = %w[Gemfile gems.rb].freeze
|
|
14
|
+
SMELL_KEYS = %w[context lines message smell_type source].freeze
|
|
15
|
+
private_constant :HOST_CONFIG_FILE, :PROJECT_ROOT_FILES, :SMELL_KEYS
|
|
16
|
+
|
|
17
|
+
def initialize(config:, files: [], diagnostic_io: $stderr)
|
|
18
|
+
@call_lock = Mutex.new
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
@call_lock.synchronize do
|
|
24
|
+
resolved_paths = existing_paths
|
|
25
|
+
next [] if files.any? && resolved_paths.empty?
|
|
26
|
+
|
|
27
|
+
@resolved_paths_for_call = resolved_paths
|
|
28
|
+
begin
|
|
29
|
+
super()
|
|
30
|
+
ensure
|
|
31
|
+
@resolved_paths_for_call = nil
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def name = "reek"
|
|
37
|
+
|
|
38
|
+
def command
|
|
39
|
+
argv = ["reek", "--format", "json"]
|
|
40
|
+
argv.concat(["--config", CONFIG_PATH]) unless host_config?
|
|
41
|
+
argv.concat(resolved_paths_for_command)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parse(stdout)
|
|
45
|
+
smells = JSON.parse(stdout)
|
|
46
|
+
raise TypeError, "report must be a JSON array" unless smells.is_a?(Array)
|
|
47
|
+
|
|
48
|
+
smells.map { |smell| build_finding(smell) }
|
|
49
|
+
rescue JSON::ParserError, KeyError, TypeError, ArgumentError => e
|
|
50
|
+
raise ParseError.new(tool: name, reason: e.message)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def existing_paths
|
|
56
|
+
files.filter_map { |path| positional_path(path) if File.exist?(path) }.freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def positional_path(path) = path.start_with?("-") ? File.join(".", path) : path
|
|
60
|
+
|
|
61
|
+
def resolved_paths_for_command = @resolved_paths_for_call || existing_paths
|
|
62
|
+
|
|
63
|
+
def host_config?
|
|
64
|
+
config_search_directories(inferred_project_root).any? do |dir|
|
|
65
|
+
File.file?(dir.join(HOST_CONFIG_FILE))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def inferred_project_root
|
|
70
|
+
PROJECT_ROOT_FILES.lazy.filter_map { |file| last_ancestor_containing(file) }.first
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def last_ancestor_containing(file)
|
|
74
|
+
Pathname(Dir.pwd).expand_path.ascend.select { |directory| directory.join(file).exist? }.last
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def config_search_directories(project_root)
|
|
78
|
+
directories = Pathname(Dir.pwd).expand_path.ascend
|
|
79
|
+
return directories.to_a unless project_root
|
|
80
|
+
|
|
81
|
+
directories.take_while { |directory| directory != project_root }.push(project_root)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_finding(smell)
|
|
85
|
+
raise TypeError, "smell entry must be an object" unless smell.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
context, lines, message, smell_type, source = smell.values_at(*SMELL_KEYS)
|
|
88
|
+
validate_smell!(context, lines, message, smell_type, source)
|
|
89
|
+
|
|
90
|
+
Finding.new(
|
|
91
|
+
tool: name,
|
|
92
|
+
file: source, line: lines.first,
|
|
93
|
+
rule: smell_type,
|
|
94
|
+
severity: :warning,
|
|
95
|
+
message: "#{context} #{message}"
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_smell!(context, lines, message, smell_type, source)
|
|
100
|
+
%w[context message smell_type source].zip([context, message, smell_type, source]) do |key, value|
|
|
101
|
+
raise TypeError, "smell #{key} must be a String" unless value.is_a?(String)
|
|
102
|
+
end
|
|
103
|
+
raise TypeError, "smell lines must be a non-empty array" if !lines.is_a?(Array) || lines.empty?
|
|
104
|
+
raise TypeError, "smell lines must contain Integers" unless lines.first.is_a?(Integer)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module QualityGate
|
|
7
|
+
module Adapters
|
|
8
|
+
# Runs RuboCop with project-aware configuration and normalizes its JSON report.
|
|
9
|
+
class RuboCop < Adapter
|
|
10
|
+
SEVERITY_MAP = {
|
|
11
|
+
"info" => :info,
|
|
12
|
+
"refactor" => :info,
|
|
13
|
+
"convention" => :warning,
|
|
14
|
+
"warning" => :warning,
|
|
15
|
+
"error" => :error,
|
|
16
|
+
"fatal" => :error
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
CONFIG_PATH = File.expand_path("../../../config/rubocop.yml", __dir__).freeze
|
|
20
|
+
|
|
21
|
+
PROJECT_CONFIG_PATHS = %w[.config/.rubocop.yml .config/rubocop/config.yml].freeze
|
|
22
|
+
PROJECT_ROOT_FILES = %w[Gemfile gems.rb].freeze
|
|
23
|
+
private_constant :PROJECT_CONFIG_PATHS, :PROJECT_ROOT_FILES
|
|
24
|
+
|
|
25
|
+
def initialize(config:, files: [], diagnostic_io: $stderr)
|
|
26
|
+
@call_lock = Mutex.new
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call
|
|
31
|
+
@call_lock.synchronize do
|
|
32
|
+
resolved_paths = existing_paths
|
|
33
|
+
next [] if files.any? && resolved_paths.empty?
|
|
34
|
+
|
|
35
|
+
@resolved_paths_for_call = resolved_paths
|
|
36
|
+
begin
|
|
37
|
+
super()
|
|
38
|
+
ensure
|
|
39
|
+
@resolved_paths_for_call = nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def name = "rubocop"
|
|
45
|
+
|
|
46
|
+
def command
|
|
47
|
+
argv = ["rubocop", "--format", "json", "--force-exclusion"]
|
|
48
|
+
explicit_config = config.to_h.fetch(:rubocop_config, nil)
|
|
49
|
+
|
|
50
|
+
if explicit_config
|
|
51
|
+
argv.concat(["--config", explicit_config])
|
|
52
|
+
elsif !host_config?
|
|
53
|
+
argv.concat(["--config", CONFIG_PATH])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
argv.concat(resolved_paths_for_command)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse(stdout)
|
|
60
|
+
report = parse_report(stdout)
|
|
61
|
+
parse_files(report.fetch("files"))
|
|
62
|
+
rescue JSON::ParserError, KeyError, TypeError, ArgumentError => e
|
|
63
|
+
raise ParseError.new(tool: name, reason: e.message)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def existing_paths
|
|
69
|
+
files.filter_map { |path| positional_path(path) if File.exist?(path) }.freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def positional_path(path) = path.start_with?("-") ? File.join(".", path) : path
|
|
73
|
+
|
|
74
|
+
def host_config?
|
|
75
|
+
project_root = inferred_project_root
|
|
76
|
+
return true if config_search_directories(project_root).any? { |dir| File.file?(dir.join(".rubocop.yml")) }
|
|
77
|
+
return false unless project_root
|
|
78
|
+
|
|
79
|
+
PROJECT_CONFIG_PATHS.any? { |path| File.file?(project_root.join(path)) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def inferred_project_root
|
|
83
|
+
PROJECT_ROOT_FILES.lazy.filter_map { |file| last_ancestor_containing(file) }.first
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def last_ancestor_containing(file)
|
|
87
|
+
Pathname(Dir.pwd).expand_path.ascend.select { |directory| directory.join(file).exist? }.last
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def config_search_directories(project_root)
|
|
91
|
+
directories = Pathname(Dir.pwd).expand_path.ascend
|
|
92
|
+
return directories.to_a unless project_root
|
|
93
|
+
|
|
94
|
+
directories.take_while { |directory| directory != project_root }.push(project_root)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def resolved_paths_for_command = @resolved_paths_for_call || existing_paths
|
|
98
|
+
|
|
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
|
+
def parse_files(entries)
|
|
107
|
+
raise TypeError, "files must be an array" unless entries.is_a?(Array)
|
|
108
|
+
|
|
109
|
+
entries.flat_map { |entry| parse_file(entry) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def parse_file(entry)
|
|
113
|
+
raise TypeError, "file entry must be an object" unless entry.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
path = entry.fetch("path")
|
|
116
|
+
offenses = entry.fetch("offenses")
|
|
117
|
+
raise TypeError, "file path must be a String" unless path.is_a?(String)
|
|
118
|
+
raise TypeError, "offenses must be an array" unless offenses.is_a?(Array)
|
|
119
|
+
|
|
120
|
+
offenses.map { |offense| build_finding(path, offense) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def build_finding(path, offense)
|
|
124
|
+
Finding.new(tool: name, file: path, **offense_attributes(offense))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def offense_attributes(offense)
|
|
128
|
+
raise TypeError, "offense must be an object" unless offense.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
location = offense.fetch("location")
|
|
131
|
+
raise TypeError, "location must be an object" unless location.is_a?(Hash)
|
|
132
|
+
|
|
133
|
+
{
|
|
134
|
+
line: location.fetch("start_line"),
|
|
135
|
+
rule: offense.fetch("cop_name"),
|
|
136
|
+
severity: SEVERITY_MAP.fetch(offense.fetch("severity")),
|
|
137
|
+
message: offense.fetch("message")
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module QualityGate
|
|
6
|
+
module Adapters
|
|
7
|
+
# Evaluates the most recent SimpleCov summary against configured budgets.
|
|
8
|
+
class SimpleCov < Adapter
|
|
9
|
+
COVERAGE_PATH = "coverage/.last_run.json"
|
|
10
|
+
|
|
11
|
+
class InvalidRecord < StandardError; end
|
|
12
|
+
private_constant :InvalidRecord
|
|
13
|
+
|
|
14
|
+
def name = "simplecov"
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
result = result_from_record
|
|
18
|
+
findings = line_findings(result)
|
|
19
|
+
findings.concat(branch_findings(result))
|
|
20
|
+
validate_findings!(findings, expected_tool: name)
|
|
21
|
+
findings
|
|
22
|
+
rescue JSON::ParserError, SystemCallError, InvalidRecord
|
|
23
|
+
[simplecov_wiring_failure]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def result_from_record
|
|
29
|
+
record = JSON.parse(File.read(COVERAGE_PATH))
|
|
30
|
+
invalid_record! unless record.is_a?(Hash)
|
|
31
|
+
result = record["result"]
|
|
32
|
+
invalid_record! unless result.is_a?(Hash)
|
|
33
|
+
percentage(result["line"])
|
|
34
|
+
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def line_findings(result)
|
|
39
|
+
minimum = coverage.fetch(:minimum_line, nil)
|
|
40
|
+
return [] unless minimum
|
|
41
|
+
|
|
42
|
+
budget_finding("line", percentage(result["line"]), minimum)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def branch_findings(result)
|
|
46
|
+
minimum = coverage.fetch(:minimum_branch, nil)
|
|
47
|
+
return [] unless minimum
|
|
48
|
+
|
|
49
|
+
actual = result["branch"]
|
|
50
|
+
return [branch_wiring_failure] unless valid_percentage?(actual)
|
|
51
|
+
|
|
52
|
+
budget_finding("branch", actual, minimum)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def budget_finding(kind, actual, minimum)
|
|
56
|
+
return [] unless actual < minimum
|
|
57
|
+
|
|
58
|
+
[
|
|
59
|
+
Finding.new(
|
|
60
|
+
tool: name, file: "", line: 0,
|
|
61
|
+
severity: :error,
|
|
62
|
+
rule: "#{kind}_coverage_below_minimum",
|
|
63
|
+
message: "#{kind} coverage #{actual}% is below configured minimum #{minimum}%"
|
|
64
|
+
)
|
|
65
|
+
]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def coverage
|
|
69
|
+
config.fetch(:coverage)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def percentage(value)
|
|
73
|
+
invalid_record! unless valid_percentage?(value)
|
|
74
|
+
|
|
75
|
+
value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def valid_percentage?(value)
|
|
79
|
+
value.is_a?(Numeric) && value.finite? && value.between?(0, 100)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def invalid_record!
|
|
83
|
+
raise InvalidRecord
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def simplecov_wiring_failure
|
|
87
|
+
Finding.tool_failure(
|
|
88
|
+
tool: name,
|
|
89
|
+
message: "#{COVERAGE_PATH} is missing or unusable; require \"simplecov\" and call " \
|
|
90
|
+
"SimpleCov.start before loading application code"
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def branch_wiring_failure
|
|
95
|
+
Finding.tool_failure(
|
|
96
|
+
tool: name,
|
|
97
|
+
message: "#{COVERAGE_PATH} has no usable branch percentage; add " \
|
|
98
|
+
"enable_coverage :branch inside SimpleCov.start"
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module QualityGate
|
|
7
|
+
module Adapters
|
|
8
|
+
# Runs the project's full test suite with coverage recording enabled.
|
|
9
|
+
class TestSuite < Adapter
|
|
10
|
+
TAIL_LINES = 20
|
|
11
|
+
LOG_DIRECTORY = File.join("log", "quality_gate").freeze
|
|
12
|
+
|
|
13
|
+
DEFAULT_COMMAND = %w[bin/rails test].map!(&:freeze).freeze
|
|
14
|
+
ENVIRONMENT = { "COVERAGE" => "1" }.freeze
|
|
15
|
+
private_constant :DEFAULT_COMMAND, :ENVIRONMENT, :LOG_DIRECTORY
|
|
16
|
+
|
|
17
|
+
def name = "test_suite"
|
|
18
|
+
|
|
19
|
+
def command
|
|
20
|
+
config.fetch(:commands).fetch(:verify).fetch(:test_suite, DEFAULT_COMMAND)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def env = ENVIRONMENT
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
tool = name
|
|
27
|
+
stderr = +""
|
|
28
|
+
argv = validated_command
|
|
29
|
+
stdout, stderr, status = capture(argv, resolved_timeout(tool), env:, combine_output: true)
|
|
30
|
+
return [] if status.success?
|
|
31
|
+
|
|
32
|
+
[failed_test_finding(stdout, stderr)]
|
|
33
|
+
# capture raises on a timeout before returning buffered output, so those
|
|
34
|
+
# failures retain the standard adapter diagnostic without a test log.
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
[failure_finding(tool, e, stderr)]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def failed_test_finding(stdout, stderr)
|
|
42
|
+
test_failure(stdout, stderr, log_path: write_failure_log(stdout, stderr))
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
test_failure(stdout, stderr, log_error: e)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def test_failure(stdout, stderr, log_path: nil, log_error: nil)
|
|
48
|
+
message = output_tail(stdout, stderr)
|
|
49
|
+
message = "#{message}\nFull test output: #{log_path}" if log_path
|
|
50
|
+
if log_error
|
|
51
|
+
message = "#{message}\nCould not write full test output log: " \
|
|
52
|
+
"#{log_error.class}: #{log_error.message}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
Finding.new(
|
|
56
|
+
tool: name,
|
|
57
|
+
file: "",
|
|
58
|
+
line: 0,
|
|
59
|
+
rule: "test_failure",
|
|
60
|
+
severity: :error,
|
|
61
|
+
message:
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def write_failure_log(stdout, stderr)
|
|
66
|
+
FileUtils.mkdir_p(LOG_DIRECTORY)
|
|
67
|
+
log = Tempfile.create(["test-suite-", ".log"], LOG_DIRECTORY)
|
|
68
|
+
log.write(stdout.to_s)
|
|
69
|
+
log.write(stderr.to_s)
|
|
70
|
+
log.flush
|
|
71
|
+
log.path
|
|
72
|
+
ensure
|
|
73
|
+
log&.close
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def output_tail(*streams)
|
|
77
|
+
streams.flat_map(&:lines).last(TAIL_LINES).join.chomp
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|