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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +571 -0
  5. data/config/37signals.yml +26 -0
  6. data/config/cops.yml +37 -0
  7. data/config/reek.yml +6 -0
  8. data/config/rubocop.yml +60 -0
  9. data/config/ruby.yml +8 -0
  10. data/docs/codex.md +31 -0
  11. data/docs/dogfood-log.md +47 -0
  12. data/docs/incidents.md +18 -0
  13. data/docs/releasing.md +46 -0
  14. data/exe/quality_gate +6 -0
  15. data/lib/generators/quality_gate/install/install_generator.rb +66 -0
  16. data/lib/generators/quality_gate/install/templates/agents_section.md.tt +15 -0
  17. data/lib/generators/quality_gate/install/templates/bullet.rb.tt +10 -0
  18. data/lib/generators/quality_gate/install/templates/claude_settings.json.tt +29 -0
  19. data/lib/generators/quality_gate/install/templates/hook_log_filesystem.rb.tt +76 -0
  20. data/lib/generators/quality_gate/install/templates/quality_gate.yml.tt +42 -0
  21. data/lib/generators/quality_gate/install/templates/quality_gate_fast.rb.tt +248 -0
  22. data/lib/generators/quality_gate/install/templates/quality_gate_verify_stop.rb.tt +466 -0
  23. data/lib/generators/quality_gate/install/templates/rubocop.yml.tt +1 -0
  24. data/lib/generators/quality_gate/install/templates/ruby_agents_section.md.tt +15 -0
  25. data/lib/generators/quality_gate/install/templates/ruby_quality_gate.yml.tt +40 -0
  26. data/lib/generators/quality_gate/install/templates/ruby_rubocop.yml.tt +1 -0
  27. data/lib/generators/quality_gate/install/templates/ruby_simplecov.rb.tt +11 -0
  28. data/lib/generators/quality_gate/install/templates/simplecov.rb.tt +10 -0
  29. data/lib/generators/quality_gate/install/templates/strong_migrations.rb.tt +10 -0
  30. data/lib/quality_gate/adapter.rb +328 -0
  31. data/lib/quality_gate/adapters/brakeman.rb +125 -0
  32. data/lib/quality_gate/adapters/bundler_audit.rb +235 -0
  33. data/lib/quality_gate/adapters/reek.rb +108 -0
  34. data/lib/quality_gate/adapters/rubocop.rb +142 -0
  35. data/lib/quality_gate/adapters/simplecov.rb +103 -0
  36. data/lib/quality_gate/adapters/test_suite.rb +81 -0
  37. data/lib/quality_gate/adapters/undercover.rb +357 -0
  38. data/lib/quality_gate/cli.rb +451 -0
  39. data/lib/quality_gate/config.rb +290 -0
  40. data/lib/quality_gate/exit_code.rb +14 -0
  41. data/lib/quality_gate/finding.rb +50 -0
  42. data/lib/quality_gate/hook_log.rb +131 -0
  43. data/lib/quality_gate/init_command.rb +90 -0
  44. data/lib/quality_gate/installation.rb +1577 -0
  45. data/lib/quality_gate/installer.rb +104 -0
  46. data/lib/quality_gate/railtie.rb +16 -0
  47. data/lib/quality_gate/reporters/field_sanitizer.rb +42 -0
  48. data/lib/quality_gate/reporters/json.rb +59 -0
  49. data/lib/quality_gate/reporters/text.rb +45 -0
  50. data/lib/quality_gate/rubocop.rb +34 -0
  51. data/lib/quality_gate/ruby_profile.rb +170 -0
  52. data/lib/quality_gate/runner.rb +150 -0
  53. data/lib/quality_gate/version.rb +5 -0
  54. data/lib/quality_gate.rb +27 -0
  55. data/lib/rubocop/cop/quality_gate/association_default_block_value.rb +46 -0
  56. data/lib/rubocop/cop/quality_gate/broadcast_in_controller.rb +64 -0
  57. data/lib/rubocop/cop/quality_gate/controller_instance_variables.rb +47 -0
  58. data/lib/rubocop/cop/quality_gate/prefer_after_save_commit.rb +84 -0
  59. data/lib/rubocop/cop/quality_gate/private_only_concern.rb +77 -0
  60. data/llm.txt +13 -0
  61. data/sig/quality_gate.rbs +226 -0
  62. 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