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,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