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,357 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module QualityGate
|
|
6
|
+
module Adapters
|
|
7
|
+
# Reports changed Ruby regions that lack coverage.
|
|
8
|
+
# rubocop:disable Metrics/ClassLength
|
|
9
|
+
class Undercover < Adapter
|
|
10
|
+
COVERAGE_PATH = "coverage/coverage.json"
|
|
11
|
+
SKIP_RULE = "undercover_skipped"
|
|
12
|
+
CLI_FOOTER_PATTERN = /\nUndercover finished in \d+(?:\.\d+)?s\n\z/
|
|
13
|
+
private_constant :CLI_FOOTER_PATTERN
|
|
14
|
+
|
|
15
|
+
def name = "undercover"
|
|
16
|
+
|
|
17
|
+
def command
|
|
18
|
+
point = compare_point
|
|
19
|
+
raise ArgumentError, "Undercover comparison point is unavailable" unless point
|
|
20
|
+
|
|
21
|
+
["undercover", "--compare", point, "--simplecov", COVERAGE_PATH, "--format", "json"]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def compare_point
|
|
25
|
+
return @compare_point if defined?(@compare_point)
|
|
26
|
+
|
|
27
|
+
@compare_point = config.fetch(:compare_point) || automatic_compare_point
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call # rubocop:disable Metrics/AbcSize
|
|
31
|
+
tool = name
|
|
32
|
+
stderr = +""
|
|
33
|
+
start_call_budget(resolved_timeout(tool))
|
|
34
|
+
return [missing_coverage_finding] unless File.file?(COVERAGE_PATH)
|
|
35
|
+
return [skip_finding] unless compare_point
|
|
36
|
+
|
|
37
|
+
stdout, stderr, status = capture_with_remaining_timeout(command)
|
|
38
|
+
validate_cli_envelope!(stdout, stderr, status)
|
|
39
|
+
findings = parse(stdout)
|
|
40
|
+
validate_cli_result!(status.exitstatus, findings)
|
|
41
|
+
validate_findings!(findings, expected_tool: tool)
|
|
42
|
+
findings
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
[failure_finding(tool, e, stderr)]
|
|
45
|
+
ensure
|
|
46
|
+
clear_call_budget
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse(stdout)
|
|
50
|
+
document = parsed_document(stdout)
|
|
51
|
+
warnings, summary = report_parts(document)
|
|
52
|
+
findings = warnings.map { finding_from(_1) }
|
|
53
|
+
validate_summary!(summary, warnings)
|
|
54
|
+
findings
|
|
55
|
+
rescue JSON::ParserError, KeyError, TypeError, ArgumentError => e
|
|
56
|
+
raise ParseError.new(tool: name, reason: e.message)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def start_call_budget(timeout_seconds)
|
|
62
|
+
@call_timeout_seconds = timeout_seconds
|
|
63
|
+
@call_deadline = monotonic_deadline(timeout_seconds)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def clear_call_budget
|
|
67
|
+
remove_instance_variable(:@call_timeout_seconds) if defined?(@call_timeout_seconds)
|
|
68
|
+
remove_instance_variable(:@call_deadline) if defined?(@call_deadline)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def capture_with_remaining_timeout(argv)
|
|
72
|
+
return capture(argv, resolved_timeout(name)) unless defined?(@call_deadline)
|
|
73
|
+
|
|
74
|
+
remaining = @call_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
75
|
+
raise TimeoutError, "timeout after #{@call_timeout_seconds} seconds" unless remaining.positive?
|
|
76
|
+
|
|
77
|
+
capture(argv, remaining)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def without_timing_footer(stdout)
|
|
81
|
+
stdout.sub(/\nUndercover finished in \d+(?:\.\d+)?s\s*\z/, "")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_cli_envelope!(stdout, stderr, status)
|
|
85
|
+
cli_contract_error!("Undercover wrote diagnostic stderr") unless stderr.strip.empty?
|
|
86
|
+
cli_contract_error!("Undercover did not exit normally") unless status&.exited?
|
|
87
|
+
|
|
88
|
+
exitstatus = status.exitstatus
|
|
89
|
+
unless [0, 1].include?(exitstatus)
|
|
90
|
+
cli_contract_error!("Undercover exited with unsupported status #{exitstatus.inspect}")
|
|
91
|
+
end
|
|
92
|
+
return if CLI_FOOTER_PATTERN.match?(stdout)
|
|
93
|
+
|
|
94
|
+
cli_contract_error!("Undercover output is missing its terminal timing footer")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate_cli_result!(exitstatus, findings)
|
|
98
|
+
expected_exitstatus = findings.empty? ? 0 : 1
|
|
99
|
+
return if exitstatus == expected_exitstatus
|
|
100
|
+
|
|
101
|
+
cli_contract_error!("Undercover exit status #{exitstatus} disagrees with its warning count")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def cli_contract_error!(reason)
|
|
105
|
+
raise ParseError.new(tool: name, reason:)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parsed_document(stdout)
|
|
109
|
+
document = JSON.parse(without_timing_footer(stdout))
|
|
110
|
+
raise TypeError, "Undercover output must be a mapping" unless document.is_a?(Hash)
|
|
111
|
+
|
|
112
|
+
document
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def report_parts(document)
|
|
116
|
+
warnings = document.fetch("warnings")
|
|
117
|
+
summary = document.fetch("summary")
|
|
118
|
+
raise TypeError, "warnings must be an Array" unless warnings.is_a?(Array)
|
|
119
|
+
raise TypeError, "summary must be a mapping" unless summary.is_a?(Hash)
|
|
120
|
+
raise TypeError, "validation must be nil" unless document["validation"].nil?
|
|
121
|
+
|
|
122
|
+
[warnings, summary]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def missing_coverage_finding
|
|
126
|
+
Finding.tool_failure(
|
|
127
|
+
tool: name,
|
|
128
|
+
message: "undercover: #{COVERAGE_PATH} is missing; SimpleCov wiring is absent. " \
|
|
129
|
+
"Run bin/rails generate quality_gate:install for Rails or " \
|
|
130
|
+
"bundle exec quality_gate init --profile ruby for plain Ruby."
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def skip_finding
|
|
135
|
+
Finding.new(
|
|
136
|
+
tool: name,
|
|
137
|
+
file: "",
|
|
138
|
+
line: 0,
|
|
139
|
+
rule: SKIP_RULE,
|
|
140
|
+
severity: :info,
|
|
141
|
+
message: skip_reason
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def skip_reason
|
|
146
|
+
return "Undercover skipped: repository is shallow; set compare_point explicitly." if shallow_repository?
|
|
147
|
+
return detached_reason if detached_head?
|
|
148
|
+
return first_commit_reason if first_commit?
|
|
149
|
+
return missing_branch_reason unless default_branch_present?
|
|
150
|
+
|
|
151
|
+
"Undercover skipped: HEAD and default branch #{default_branch_name} have no common ancestor."
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def detached_reason
|
|
155
|
+
"Undercover skipped: HEAD is detached from #{default_branch_name} with no shared ancestor."
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def first_commit_reason
|
|
159
|
+
"Undercover skipped: this is the first commit, so there is no earlier commit to compare against."
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def missing_branch_reason
|
|
163
|
+
"Undercover skipped: default branch #{default_branch_name} is missing locally."
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def shallow_repository?
|
|
167
|
+
git_output("rev-parse", "--is-shallow-repository") == "true"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def detached_head?
|
|
171
|
+
git_output("symbolic-ref", "--quiet", "HEAD", expected_exitstatuses: [1]).nil?
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def first_commit?
|
|
175
|
+
commit_and_parents = git_output("rev-list", "--parents", "-n", "1", "HEAD")
|
|
176
|
+
commit_and_parents&.split&.one?
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def automatic_compare_point
|
|
180
|
+
branch = default_branch_ref
|
|
181
|
+
return unless default_branch_present?
|
|
182
|
+
return if first_commit? && current_branch_name == default_branch_name
|
|
183
|
+
|
|
184
|
+
merge_base(branch)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def current_branch_name
|
|
188
|
+
git_output("symbolic-ref", "--quiet", "--short", "HEAD", expected_exitstatuses: [1])
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def default_branch_present?
|
|
192
|
+
default_branch_ref unless defined?(@default_branch_present)
|
|
193
|
+
@default_branch_present
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def default_branch_name
|
|
197
|
+
default_branch_ref unless defined?(@default_branch_name)
|
|
198
|
+
@default_branch_name
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def default_branch_ref
|
|
202
|
+
return @default_branch_ref if defined?(@default_branch_ref)
|
|
203
|
+
|
|
204
|
+
@default_branch_ref = remote_default_branch || local_default_branch
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def remote_default_branch
|
|
208
|
+
branch = git_output(
|
|
209
|
+
"symbolic-ref", "--quiet", "refs/remotes/origin/HEAD",
|
|
210
|
+
expected_exitstatuses: [1]
|
|
211
|
+
)
|
|
212
|
+
return unless branch
|
|
213
|
+
return unless commit_exists?(branch)
|
|
214
|
+
|
|
215
|
+
@default_branch_present = true
|
|
216
|
+
@default_branch_name = branch.delete_prefix("refs/remotes/origin/")
|
|
217
|
+
branch
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def local_default_branch
|
|
221
|
+
%w[main master].each do |branch|
|
|
222
|
+
ref = "refs/heads/#{branch}"
|
|
223
|
+
next unless commit_exists?(ref)
|
|
224
|
+
|
|
225
|
+
@default_branch_present = true
|
|
226
|
+
@default_branch_name = branch
|
|
227
|
+
return ref
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
@default_branch_present = false
|
|
231
|
+
@default_branch_name = "main"
|
|
232
|
+
"refs/heads/main"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def merge_base(branch)
|
|
236
|
+
git_output("merge-base", "HEAD", branch, expected_exitstatuses: [1])
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def commit_exists?(ref)
|
|
240
|
+
git_output("rev-parse", "--verify", "--quiet", "#{ref}^{commit}", expected_exitstatuses: [1])
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def git_output(*arguments, expected_exitstatuses: [])
|
|
244
|
+
stdout, stderr, status = capture_with_remaining_timeout(git_command(arguments))
|
|
245
|
+
return if expected_git_status?(status, stderr, expected_exitstatuses)
|
|
246
|
+
|
|
247
|
+
raise_git_error(arguments, stderr, status) unless status&.success? && stderr.empty?
|
|
248
|
+
|
|
249
|
+
value = stdout.strip
|
|
250
|
+
value unless value.empty?
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def expected_git_status?(status, stderr, expected_exitstatuses)
|
|
254
|
+
status && stderr.empty? && expected_exitstatuses.include?(status.exitstatus)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def git_command(arguments)
|
|
258
|
+
["git", *arguments]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def raise_git_error(arguments, stderr, status)
|
|
262
|
+
result = status ? "exit #{status.exitstatus}" : "no exit status"
|
|
263
|
+
diagnostic = stderr.strip
|
|
264
|
+
diagnostic = "; stderr: #{diagnostic}" unless diagnostic.empty?
|
|
265
|
+
raise "git #{arguments.join(" ")} failed (#{result})#{diagnostic}"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def finding_from(warning)
|
|
269
|
+
validate_warning!(warning)
|
|
270
|
+
|
|
271
|
+
Finding.new(
|
|
272
|
+
tool: name,
|
|
273
|
+
file: warning.fetch("file"),
|
|
274
|
+
line: warning.fetch("first_line"),
|
|
275
|
+
rule: "uncovered_code",
|
|
276
|
+
severity: :warning,
|
|
277
|
+
message: warning_message(warning)
|
|
278
|
+
)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def validate_summary!(summary, warnings)
|
|
282
|
+
total_warnings = summary.fetch("total_warnings")
|
|
283
|
+
files_affected = summary.fetch("files_affected")
|
|
284
|
+
unless non_negative_integer?(total_warnings) && non_negative_integer?(files_affected)
|
|
285
|
+
raise TypeError, "summary counts must be non-negative Integers"
|
|
286
|
+
end
|
|
287
|
+
raise TypeError, "summary total_warnings does not match warnings" unless total_warnings == warnings.length
|
|
288
|
+
|
|
289
|
+
unique_files = warnings.map { _1.fetch("file") }.uniq.length
|
|
290
|
+
raise TypeError, "summary files_affected does not match warning files" unless files_affected == unique_files
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def non_negative_integer?(value)
|
|
294
|
+
value.is_a?(Integer) && value >= 0
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def validate_warning!(warning)
|
|
298
|
+
return if warning.is_a?(Hash) && valid_warning?(warning)
|
|
299
|
+
|
|
300
|
+
raise TypeError, "warning is malformed"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def valid_warning?(warning)
|
|
304
|
+
required_strings?(warning) && valid_range?(warning) && warning["coverage"].is_a?(Numeric) &&
|
|
305
|
+
valid_lines?(warning["uncovered_lines"]) && valid_branches?(warning["uncovered_branches"])
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def required_strings?(warning)
|
|
309
|
+
warning.values_at("node", "type", "file").all? { _1.is_a?(String) && !_1.empty? }
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def valid_range?(warning)
|
|
313
|
+
first_line, last_line = warning.values_at("first_line", "last_line")
|
|
314
|
+
first_line.is_a?(Integer) && first_line.positive? && last_line.is_a?(Integer) && last_line >= first_line
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def valid_lines?(lines)
|
|
318
|
+
lines.is_a?(Array) && lines.all? { _1.is_a?(Integer) && _1 >= 0 }
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def valid_branches?(branches)
|
|
322
|
+
branches.is_a?(Array) && branches.all? { valid_branch?(_1) }
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def valid_branch?(branch)
|
|
326
|
+
return false unless branch.is_a?(Hash)
|
|
327
|
+
|
|
328
|
+
numbers = branch.values_at("line", "block", "branch")
|
|
329
|
+
description = branch["description"]
|
|
330
|
+
numbers.all? { _1.is_a?(Integer) && _1 >= 0 } && (description.nil? || description.is_a?(String))
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def warning_message(warning)
|
|
334
|
+
"#{warning.fetch("type")} #{warning.fetch("node")} lines " \
|
|
335
|
+
"#{warning.fetch("first_line")}-#{warning.fetch("last_line")}: " \
|
|
336
|
+
"coverage #{warning.fetch("coverage")}; " \
|
|
337
|
+
"uncovered lines #{list_or_none(warning.fetch("uncovered_lines"))}; " \
|
|
338
|
+
"uncovered branches #{branch_list_or_none(warning.fetch("uncovered_branches"))}"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def list_or_none(values)
|
|
342
|
+
values.empty? ? "none" : values.join(", ")
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def branch_list_or_none(branches)
|
|
346
|
+
return "none" if branches.empty?
|
|
347
|
+
|
|
348
|
+
branches.map do |branch|
|
|
349
|
+
label = "line #{branch.fetch("line")} block #{branch.fetch("block")} branch #{branch.fetch("branch")}"
|
|
350
|
+
description = branch["description"]
|
|
351
|
+
description ? "#{label} (#{description})" : label
|
|
352
|
+
end.join(", ")
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
# rubocop:enable Metrics/ClassLength
|
|
356
|
+
end
|
|
357
|
+
end
|