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,451 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module QualityGate
6
+ # Parses command-line input and coordinates configured quality gates.
7
+ # rubocop:disable Metrics/ClassLength
8
+ class CLI
9
+ SUBCOMMANDS = %w[fast verify audit version init].map!(&:freeze).freeze
10
+ GATE_SUBCOMMANDS = SUBCOMMANDS.first(3).freeze
11
+ GATE_TOOLS = {
12
+ "fast" => %w[rubocop].freeze,
13
+ "verify" => %w[reek test_suite undercover].freeze,
14
+ "audit" => %w[brakeman bundler_audit].freeze
15
+ }.freeze
16
+
17
+ class << self
18
+ def run(argv, stdout: $stdout, stderr: $stderr, dir: Dir.pwd)
19
+ output_format = nil
20
+ arguments = argv.dup
21
+ output_format = requested_format(arguments)
22
+ subcommand = arguments.shift
23
+
24
+ early_result = early_command(subcommand, arguments, stdout: stdout, stderr: stderr, dir: dir)
25
+ return early_result unless early_result.nil?
26
+
27
+ output_format ||= configured_format(dir)
28
+ execute_command(
29
+ subcommand,
30
+ arguments,
31
+ stdout: stdout,
32
+ stderr: stderr,
33
+ dir: dir,
34
+ format: output_format
35
+ )
36
+ rescue OptionParser::ParseError => e
37
+ report_option_error(e, stdout: stdout, stderr: stderr, format: output_format)
38
+ rescue StandardError => e
39
+ report_error(e, stdout: stdout, stderr: stderr, format: output_format)
40
+ end
41
+
42
+ private
43
+
44
+ def print_version(stdout, stderr)
45
+ return ExitCode::CLEAN if write_line(stdout, VERSION)
46
+
47
+ write_diagnostic(stderr, "Error", "could not write version output")
48
+ ExitCode::TOOL_FAILURE
49
+ end
50
+
51
+ def invalid_command(subcommand, stdout:, stderr:, format:)
52
+ return report_json_error("unknown subcommand #{subcommand}", stdout: stdout, stderr: stderr) if format == "json"
53
+
54
+ usage(stderr)
55
+ ExitCode::TOOL_FAILURE
56
+ end
57
+
58
+ def early_command(subcommand, arguments, stdout:, stderr:, dir:)
59
+ if help_requested?(subcommand, arguments)
60
+ return print_help(stdout, stderr, gate: help_gate(subcommand, arguments))
61
+ end
62
+ return print_version(stdout, stderr) if subcommand == "version" && arguments.empty?
63
+ return run_init(arguments, stdout: stdout, stderr: stderr, dir: dir) if subcommand == "init"
64
+
65
+ nil
66
+ end
67
+
68
+ def run_init(arguments, stdout:, stderr:, dir:)
69
+ require_relative "init_command" unless defined?(QualityGate::InitCommand)
70
+
71
+ InitCommand.run(arguments, stdout: stdout, stderr: stderr, dir: dir)
72
+ end
73
+
74
+ def execute_command(subcommand, arguments, stdout:, stderr:, dir:, format:)
75
+ unless GATE_SUBCOMMANDS.include?(subcommand)
76
+ return invalid_command(subcommand, stdout: stdout, stderr: stderr, format: format)
77
+ end
78
+
79
+ emit_hook_warning(dir, stderr)
80
+ run_gate(subcommand, arguments, stdout: stdout, stderr: stderr, dir: dir)
81
+ end
82
+
83
+ def run_gate(subcommand, arguments, stdout:, stderr:, dir:)
84
+ overrides = parse(arguments)
85
+ config = Config.load(dir: dir)
86
+ return ExitCode::TOOL_FAILURE unless warn_unknown_keys(config, stderr)
87
+
88
+ settings = config.to_h.merge(overrides)
89
+ validate_paths!(settings.fetch(:files), dir)
90
+ validate_simplecov_configuration!(subcommand, settings, config.path || File.join(dir, ".quality_gate.yml"))
91
+ dispatch(subcommand, settings, stdout: stdout, stderr: stderr)
92
+ end
93
+
94
+ def emit_hook_warning(dir, stderr)
95
+ path = File.join(dir, HookLog::DEFAULT_PATH)
96
+ warning = HookLog.new(path: path).warning_line
97
+ write_line(stderr, warning) if warning
98
+ rescue StandardError
99
+ nil
100
+ end
101
+
102
+ def report_option_error(error, stdout:, stderr:, format:)
103
+ return report_json_error(error, stdout: stdout, stderr: stderr) if format == "json"
104
+
105
+ write_error(stderr, error)
106
+ usage(stderr)
107
+ ExitCode::TOOL_FAILURE
108
+ end
109
+
110
+ def report_error(error, stdout:, stderr:, format:)
111
+ return report_json_error(error, stdout: stdout, stderr: stderr) if format == "json"
112
+
113
+ write_error(stderr, error)
114
+ ExitCode::TOOL_FAILURE
115
+ end
116
+
117
+ def warn_unknown_keys(config, stderr)
118
+ config.unknown_keys.all? do |key|
119
+ write_diagnostic(stderr, "Warning", "unknown config key #{key}")
120
+ end
121
+ end
122
+
123
+ def usage(stderr)
124
+ write_line(stderr, "Usage: quality_gate <#{SUBCOMMANDS.join("|")}> [options]")
125
+ end
126
+
127
+ def write_line(io, message)
128
+ io.puts message
129
+ true
130
+ rescue StandardError
131
+ false
132
+ end
133
+
134
+ def write_diagnostic(io, prefix, detail)
135
+ write_line(io, "#{prefix}: #{normalize_diagnostic(detail)}")
136
+ end
137
+
138
+ def write_error(io, error)
139
+ write_diagnostic(io, "Error", error.message)
140
+ rescue StandardError
141
+ write_diagnostic(io, "Error", "quality_gate failed")
142
+ end
143
+
144
+ def normalize_diagnostic(detail)
145
+ detail.to_s
146
+ .encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�")
147
+ .gsub(/(?:[[:cntrl:]]|[\u2028\u2029])+/, " ")
148
+ .strip
149
+ rescue StandardError
150
+ "unavailable diagnostic"
151
+ end
152
+
153
+ def parse(arguments)
154
+ overrides = {}
155
+ option_parser(overrides).parse!(arguments)
156
+ append_remaining_files(overrides, arguments)
157
+ validate_files(overrides)
158
+
159
+ overrides
160
+ end
161
+
162
+ def option_parser(overrides)
163
+ OptionParser.new.tap do |parser|
164
+ parser.on("--files PATH") do |path|
165
+ raise OptionParser::InvalidOption, "--files" if overrides.key?(:files)
166
+
167
+ overrides[:files] = [path]
168
+ end
169
+ parser.on("--format FORMAT", %w[text json]) { |format| overrides[:format] = format }
170
+ parser.on("-h", "--help") { overrides[:help] = true }
171
+ end
172
+ end
173
+
174
+ def append_remaining_files(overrides, arguments)
175
+ if overrides.key?(:files)
176
+ overrides[:files].concat(arguments)
177
+ elsif arguments.any?
178
+ raise OptionParser::InvalidOption, arguments.join(" ")
179
+ end
180
+ end
181
+
182
+ def validate_files(overrides)
183
+ return unless overrides.key?(:files) && overrides[:files].any?(&:empty?)
184
+
185
+ raise OptionParser::InvalidArgument, "--files requires a non-empty path"
186
+ end
187
+
188
+ def validate_paths!(files, dir)
189
+ missing = files.reject { |path| File.exist?(File.expand_path(path, dir)) }
190
+ return if missing.empty?
191
+
192
+ raise ArgumentError, "missing files or directories: #{missing.join(", ")}"
193
+ end
194
+
195
+ def validate_simplecov_configuration!(subcommand, settings, path)
196
+ adapters = settings.fetch(:adapters).fetch(subcommand.to_sym)
197
+ return unless adapters.include?("simplecov")
198
+
199
+ coverage = settings.fetch(:coverage)
200
+ return if coverage.is_a?(Hash) && %i[minimum_line minimum_branch].any? { coverage.key?(_1) }
201
+
202
+ cause = "simplecov adapter requires coverage.minimum_line and/or coverage.minimum_branch"
203
+ raise ConfigError.new(path: path, cause_message: cause)
204
+ end
205
+
206
+ def dispatch(subcommand, settings, stdout:, stderr:)
207
+ config = Config.new(settings)
208
+ diagnostic_io = terminal_diagnostic_io(stderr)
209
+ result = Runner.new(
210
+ adapters: adapters_for(subcommand, settings: settings, config: config, diagnostic_io: stderr),
211
+ config: config,
212
+ diagnostic_io: diagnostic_io
213
+ ).call
214
+
215
+ begin
216
+ reporter_for(settings.fetch(:format), stdout: stdout).call(result)
217
+ rescue StandardError => e
218
+ write_error(stderr, e)
219
+ return ExitCode::TOOL_FAILURE
220
+ end
221
+
222
+ result.exit_code
223
+ end
224
+
225
+ def adapters_for(subcommand, settings:, config:, diagnostic_io:)
226
+ settings.fetch(:adapters).fetch(subcommand.to_sym).map do |adapter_name|
227
+ build_adapter(
228
+ adapter_name.to_s,
229
+ config: config,
230
+ files: settings.fetch(:files),
231
+ diagnostic_io: diagnostic_io
232
+ )
233
+ end
234
+ end
235
+
236
+ def build_adapter(adapter_name, config:, files:, diagnostic_io:)
237
+ registry.fetch(adapter_name) do
238
+ return UnknownAdapter.new(adapter_name)
239
+ end.new(config: config, files: files, diagnostic_io: diagnostic_io)
240
+ end
241
+
242
+ def reporter_for(format, stdout:)
243
+ case format
244
+ when "json"
245
+ Reporters::Json.new(io: stdout)
246
+ else
247
+ Reporters::Text.new(io: stdout)
248
+ end
249
+ end
250
+
251
+ def help_requested?(subcommand, arguments)
252
+ return true if %w[-h --help].include?(subcommand)
253
+ return true if subcommand == "help"
254
+
255
+ (GATE_SUBCOMMANDS.include?(subcommand) || subcommand == "init") && help_argument?(arguments)
256
+ end
257
+
258
+ def help_argument?(arguments)
259
+ arguments.take_while { _1 != "--" }.any? { %w[-h --help].include?(_1) }
260
+ end
261
+
262
+ def help_gate(subcommand, arguments)
263
+ return arguments.find { GATE_SUBCOMMANDS.include?(_1) || _1 == "init" } if subcommand == "help"
264
+ return "init" if subcommand == "init"
265
+ return subcommand if GATE_SUBCOMMANDS.include?(subcommand)
266
+
267
+ nil
268
+ end
269
+
270
+ def print_help(stdout, stderr, gate: nil)
271
+ return write_help_failure(stderr) unless write_line(stdout, help_text(gate))
272
+
273
+ ExitCode::CLEAN
274
+ end
275
+
276
+ def write_help_failure(stderr)
277
+ write_diagnostic(stderr, "Error", "could not write help output")
278
+ ExitCode::TOOL_FAILURE
279
+ end
280
+
281
+ def help_text(gate)
282
+ return init_help_text if gate == "init"
283
+ return gate_help_text(gate) if gate
284
+
285
+ <<~TEXT
286
+ Usage: quality_gate <fast|verify|audit|version|init> [options]
287
+
288
+ Run one Quality Gate check for the current project.
289
+
290
+ Commands:
291
+ fast RuboCop (quick feedback for selected files)
292
+ verify Reek, test_suite, and Undercover (tests and coverage)
293
+ audit Brakeman and bundler_audit (security checks)
294
+ version Print the Quality Gate version
295
+ init Install a plain Ruby project configuration
296
+
297
+ Options:
298
+ --files PATH [PATH ...] Replace configured paths for this run; files and directories are allowed
299
+ --format FORMAT Choose text or json output (also accepts --format=FORMAT)
300
+ -h, --help Show this help
301
+
302
+ Scope:
303
+ Without --files, the configured files are used. With --files, the command-line paths replace them.
304
+ RuboCop and Reek scan selected paths. The test suite runs in full; Undercover checks the Git diff.
305
+ Brakeman scans the application and bundler-audit scans the lockfile.
306
+
307
+ Examples:
308
+ quality_gate fast
309
+ quality_gate fast --files app/models/user.rb test/models
310
+ quality_gate verify --format json
311
+
312
+ Exit status:
313
+ 0 Checks completed cleanly
314
+ 1 Checks completed with findings
315
+ 2 Quality Gate could not complete (input, config, or tool failure)
316
+ TEXT
317
+ end
318
+
319
+ def gate_help_text(gate)
320
+ tools = GATE_TOOLS.fetch(gate).join(", ")
321
+
322
+ <<~TEXT
323
+ Usage: quality_gate #{gate} [options]
324
+
325
+ Run the #{gate} gate using these default tools: #{tools}.
326
+
327
+ Options:
328
+ --files PATH [PATH ...] Replace configured paths for this run; files and directories are allowed
329
+ --format FORMAT Choose text or json output (also accepts --format=FORMAT)
330
+ -h, --help Show this help
331
+
332
+ Scope:
333
+ Without --files, the configured files are used. With --files, the command-line paths replace them.
334
+ RuboCop and Reek scan selected paths. The test suite runs in full; Undercover checks the Git diff.
335
+ Brakeman scans the application and bundler-audit scans the lockfile.
336
+
337
+ Examples:
338
+ quality_gate #{gate}
339
+ quality_gate #{gate} --files app/models/user.rb test/models
340
+ quality_gate #{gate} --format json
341
+
342
+ Exit status:
343
+ 0 Checks completed cleanly
344
+ 1 Checks completed with findings
345
+ 2 Quality Gate could not complete (input, config, or tool failure)
346
+ TEXT
347
+ end
348
+
349
+ def init_help_text
350
+ <<~TEXT
351
+ Usage: quality_gate init [options]
352
+
353
+ Install a plain Ruby Quality Gate configuration (Ruby is the default profile).
354
+
355
+ Options:
356
+ --profile PROFILE Select the setup profile (ruby)
357
+ --test-framework NAME Select minitest or rspec
358
+ --test-helper PATH Use a custom test helper
359
+ --test-command COMMAND Override the test command
360
+ --skip-coverage Omit coverage and Undercover setup
361
+ --agents Install optional agent hooks and contracts
362
+ --pretend Preview changes without writing them
363
+ -h, --help Show this help
364
+
365
+ Init writes a human-readable summary. Gate commands continue to support text and JSON output.
366
+
367
+ Examples:
368
+ quality_gate init
369
+ quality_gate init --test-framework rspec
370
+ quality_gate init --pretend
371
+ TEXT
372
+ end
373
+
374
+ def requested_format(arguments)
375
+ format = nil
376
+ explicit = false
377
+ arguments.take_while { _1 != "--" }.each_with_index do |argument, index|
378
+ if argument.start_with?("--format=")
379
+ explicit = true
380
+ format = argument.delete_prefix("--format=")
381
+ elsif argument == "--format"
382
+ explicit = true
383
+ format = arguments[index + 1]
384
+ end
385
+ end
386
+
387
+ explicit && format.nil? ? :invalid : format
388
+ end
389
+
390
+ def configured_format(dir)
391
+ return unless dir.is_a?(String)
392
+
393
+ path = File.join(dir, ".quality_gate.yml")
394
+ return unless File.file?(path)
395
+
396
+ contents = File.read(path)
397
+ document = YAML.safe_load(contents)
398
+ format = document.is_a?(Hash) && document["format"]
399
+ return format if %w[text json].include?(format)
400
+
401
+ nil
402
+ rescue Psych::Exception, SystemCallError, SystemStackError
403
+ nil
404
+ end
405
+
406
+ def terminal_diagnostic_io(io)
407
+ io if io.respond_to?(:tty?) && io.tty?
408
+ rescue StandardError
409
+ nil
410
+ end
411
+
412
+ def report_json_error(error, stdout:, stderr:)
413
+ result = Runner::Result.new(
414
+ findings: [Finding.tool_failure(tool: "quality_gate", message: safe_error_message(error))]
415
+ )
416
+ reporter_for("json", stdout: stdout).call(result)
417
+ ExitCode::TOOL_FAILURE
418
+ rescue StandardError => e
419
+ write_error(stderr, e)
420
+ ExitCode::TOOL_FAILURE
421
+ end
422
+
423
+ def safe_error_message(error)
424
+ message = error.is_a?(String) ? error : error.message
425
+ message.is_a?(String) ? message : message.to_s
426
+ rescue StandardError
427
+ "quality_gate failed"
428
+ end
429
+
430
+ def registry
431
+ {
432
+ "reek" => QualityGate::Adapters::Reek,
433
+ "rubocop" => QualityGate::Adapters::RuboCop,
434
+ "simplecov" => QualityGate::Adapters::SimpleCov,
435
+ "test_suite" => QualityGate::Adapters::TestSuite,
436
+ "undercover" => QualityGate::Adapters::Undercover,
437
+ "brakeman" => QualityGate::Adapters::Brakeman,
438
+ "bundler_audit" => QualityGate::Adapters::BundlerAudit
439
+ }.freeze
440
+ end
441
+
442
+ UnknownAdapter = Data.define(:name) do
443
+ def call
444
+ [Finding.tool_failure(tool: name, message: "unknown adapter #{name}")]
445
+ end
446
+ end
447
+ private_constant :UnknownAdapter
448
+ end
449
+ end
450
+ # rubocop:enable Metrics/ClassLength
451
+ end