quality_gate 0.2.2 → 0.3.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +292 -21
  4. data/docs/codex.md +37 -9
  5. data/docs/releasing.md +16 -4
  6. data/lib/generators/quality_gate/install/install_generator.rb +18 -3
  7. data/lib/generators/quality_gate/install/templates/agents_section.md.tt +13 -2
  8. data/lib/generators/quality_gate/install/templates/codex_fast.rb.tt +36 -0
  9. data/lib/generators/quality_gate/install/templates/codex_hooks.json.tt +1 -0
  10. data/lib/generators/quality_gate/install/templates/codex_verify_stop.rb.tt +28 -0
  11. data/lib/generators/quality_gate/install/templates/quality_gate.yml.tt +6 -0
  12. data/lib/generators/quality_gate/install/templates/quality_gate_fast.rb.tt +16 -3
  13. data/lib/generators/quality_gate/install/templates/quality_gate_workflow.yml.tt +28 -0
  14. data/lib/generators/quality_gate/install/templates/rails_quality_gate.yml.tt +45 -0
  15. data/lib/generators/quality_gate/install/templates/ruby_agents_section.md.tt +13 -2
  16. data/lib/generators/quality_gate/install/templates/ruby_quality_gate.yml.tt +5 -0
  17. data/lib/generators/quality_gate/install/templates/simplecov.rb.tt +1 -0
  18. data/lib/quality_gate/adapters/bundler_audit.rb +28 -3
  19. data/lib/quality_gate/adapters/database_consistency.rb +51 -0
  20. data/lib/quality_gate/adapters/database_consistency_report.rb +61 -0
  21. data/lib/quality_gate/adapters/database_consistency_report_item.rb +122 -0
  22. data/lib/quality_gate/adapters/debride.rb +67 -0
  23. data/lib/quality_gate/adapters/debride_report.rb +80 -0
  24. data/lib/quality_gate/adapters/herb.rb +303 -0
  25. data/lib/quality_gate/adapters/reek.rb +21 -2
  26. data/lib/quality_gate/adapters/rubocop.rb +23 -9
  27. data/lib/quality_gate/adapters/rubycritic.rb +74 -0
  28. data/lib/quality_gate/adapters/rubycritic_report.rb +112 -0
  29. data/lib/quality_gate/adapters/test_suite.rb +12 -1
  30. data/lib/quality_gate/baseline.rb +269 -0
  31. data/lib/quality_gate/baseline_run.rb +80 -0
  32. data/lib/quality_gate/cli.rb +129 -6
  33. data/lib/quality_gate/codex_fast_hook.rb +163 -0
  34. data/lib/quality_gate/codex_patch_files.rb +95 -0
  35. data/lib/quality_gate/codex_stop_hook.rb +102 -0
  36. data/lib/quality_gate/config.rb +20 -6
  37. data/lib/quality_gate/database_consistency_runner.rb +124 -0
  38. data/lib/quality_gate/doctor.rb +217 -0
  39. data/lib/quality_gate/doctor_bounded_file.rb +74 -0
  40. data/lib/quality_gate/doctor_command.rb +130 -0
  41. data/lib/quality_gate/doctor_coverage.rb +113 -0
  42. data/lib/quality_gate/doctor_git.rb +121 -0
  43. data/lib/quality_gate/doctor_hooks.rb +99 -0
  44. data/lib/quality_gate/doctor_launchers.rb +300 -0
  45. data/lib/quality_gate/doctor_path_lookup.rb +52 -0
  46. data/lib/quality_gate/doctor_report.rb +38 -0
  47. data/lib/quality_gate/init_command.rb +3 -1
  48. data/lib/quality_gate/installation.rb +72 -17
  49. data/lib/quality_gate/installer.rb +3 -0
  50. data/lib/quality_gate/reporters/doctor.rb +57 -0
  51. data/lib/quality_gate/reporters/json.rb +18 -1
  52. data/lib/quality_gate/reporters/markdown.rb +11 -1
  53. data/lib/quality_gate/reporters/text.rb +14 -6
  54. data/lib/quality_gate/ruby_profile.rb +121 -2
  55. data/lib/quality_gate/runner.rb +15 -17
  56. data/lib/quality_gate/version.rb +1 -1
  57. data/lib/quality_gate.rb +15 -0
  58. data/sig/quality_gate.rbs +117 -2
  59. metadata +30 -1
@@ -22,20 +22,23 @@ module QualityGate
22
22
  adapters: {
23
23
  fast: ["rubocop"].freeze,
24
24
  verify: %w[reek test_suite undercover].map!(&:freeze).freeze,
25
- audit: %w[brakeman bundler_audit].map!(&:freeze).freeze
25
+ audit: %w[brakeman bundler_audit].map!(&:freeze).freeze,
26
+ deep: ["rubycritic"].freeze
26
27
  }.freeze,
27
28
  commands: {
28
29
  fast: {}.freeze,
29
30
  verify: { test_suite: %w[bin/rails test].map!(&:freeze).freeze }.freeze,
30
- audit: {}.freeze
31
+ audit: {}.freeze,
32
+ deep: {}.freeze
31
33
  }.freeze,
32
34
  timeouts: { default: 120, rubocop: 10, test_suite: 120, undercover: 120 }.freeze,
33
35
  coverage: nil,
34
36
  compare_point: nil,
35
- rubocop_config: nil
37
+ rubocop_config: nil,
38
+ baseline: {}.freeze
36
39
  }.freeze
37
40
  FORMATS = %w[text json markdown].map!(&:freeze).freeze
38
- ADAPTER_LAYERS = %i[fast verify audit].freeze
41
+ ADAPTER_LAYERS = %i[fast verify audit deep].freeze
39
42
  COVERAGE_KEYS = %i[minimum_line minimum_branch].freeze
40
43
  private_constant :ADAPTER_LAYERS, :COVERAGE_KEYS
41
44
 
@@ -69,6 +72,7 @@ module QualityGate
69
72
  validate_document(document, path)
70
73
  overrides = symbolize_keys(document || {})
71
74
  validate_known_settings(overrides, path)
75
+ validate_baseline(overrides[:baseline], path) if overrides.key?(:baseline)
72
76
 
73
77
  new(
74
78
  deep_merge(DEFAULTS, known_overrides(overrides)),
@@ -121,7 +125,7 @@ module QualityGate
121
125
 
122
126
  invalid_keys = adapters.keys - ADAPTER_LAYERS
123
127
  if invalid_keys.any?
124
- fail ConfigError.new(path: path, cause_message: "adapters keys must be fast, verify, or audit") # rubocop:disable Style/SignalException
128
+ fail ConfigError.new(path: path, cause_message: "adapters keys must be fast, verify, audit, or deep") # rubocop:disable Style/SignalException
125
129
  end
126
130
 
127
131
  adapters.each do |layer, adapter_names|
@@ -160,7 +164,7 @@ module QualityGate
160
164
 
161
165
  return if (value.keys - ADAPTER_LAYERS).empty?
162
166
 
163
- cause = "#{setting} keys must be fast, verify, or audit"
167
+ cause = "#{setting} keys must be fast, verify, audit, or deep"
164
168
  fail ConfigError.new(path: path, cause_message: cause) # rubocop:disable Style/SignalException
165
169
  end
166
170
 
@@ -220,6 +224,16 @@ module QualityGate
220
224
  fail ConfigError.new(path: path, cause_message: "rubocop_config must be nil or a non-empty String") # rubocop:disable Style/SignalException
221
225
  end
222
226
 
227
+ def validate_baseline(baseline, path)
228
+ unless baseline.is_a?(Hash) && (baseline.keys - %i[fast verify]).empty?
229
+ raise ConfigError.new(path: path, cause_message: "baseline must map only fast and verify")
230
+ end
231
+
232
+ return if baseline.all? { |gate, file| %i[fast verify].include?(gate) && file.is_a?(String) && !file.empty? }
233
+
234
+ raise ConfigError.new(path: path, cause_message: "baseline paths must be non-empty strings")
235
+ end
236
+
223
237
  def validate_document(document, path)
224
238
  unless document.nil? || document.is_a?(Hash)
225
239
  cause = "expected a mapping, got #{document.class}"
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rubygems"
5
+
6
+ module QualityGate
7
+ # Runs database_consistency inside the target application's Bundler/Rails context.
8
+ class DatabaseConsistencyRunner
9
+ ENVELOPE_VERSION = 1
10
+ SUPPORTED_ANALYZER_VERSION = Gem::Requirement.new("~> 3.0.14")
11
+ REPORT_FIELDS = %i[
12
+ checker_name table_or_model_name column_or_attribute_name status error_slug error_message
13
+ ].freeze
14
+ private_constant :REPORT_FIELDS
15
+
16
+ class << self
17
+ def run(stdout: $stdout, stderr: $stderr, cwd: Dir.pwd)
18
+ with_redirected_stdout(stderr) { execute(stdout, cwd) }
19
+ rescue StandardError, LoadError => e
20
+ diagnostic(stderr, e)
21
+ 2
22
+ end
23
+
24
+ private
25
+
26
+ def load_project!(cwd)
27
+ require "bundler/setup"
28
+ require_project_files(cwd)
29
+ rails_application.eager_load!
30
+ end
31
+
32
+ def require_project_files(cwd)
33
+ require_project_file(cwd, "boot")
34
+ require_project_file(cwd, "environment")
35
+ end
36
+
37
+ def load_analyzer!
38
+ require "database_consistency"
39
+ spec = Gem.loaded_specs["database_consistency"]
40
+ validate_analyzer_version!(spec)
41
+ spec.version.to_s
42
+ end
43
+
44
+ def collect_reports
45
+ configuration = DatabaseConsistency::Configuration.new
46
+ reports = DatabaseConsistency::Processors.reports(configuration)
47
+ ensure_no_rescued_checker_errors!
48
+ serialize_reports(reports)
49
+ end
50
+
51
+ def serialize_report(report)
52
+ report_fields(report).merge(report_source_location(report))
53
+ end
54
+
55
+ def report_fields(report)
56
+ REPORT_FIELDS.to_h { |field| [field, report.public_send(field)] }
57
+ end
58
+
59
+ def report_source_location(report)
60
+ return {} unless report.respond_to?(:source_location)
61
+
62
+ { source_location: report.source_location }
63
+ end
64
+
65
+ def write_envelope(stdout, analyzer_version, reports)
66
+ stdout.puts(JSON.generate(version: ENVELOPE_VERSION, analyzer_version:, reports:))
67
+ end
68
+
69
+ def diagnostic(stderr, error)
70
+ stderr.puts("database_consistency: #{error.class}: #{error.message}")
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ def with_redirected_stdout(stderr)
76
+ previous_stdout = $stdout
77
+ $stdout = stderr
78
+ yield
79
+ ensure
80
+ $stdout = previous_stdout
81
+ end
82
+
83
+ def execute(stdout, cwd)
84
+ load_project!(cwd)
85
+ analyzer_version = load_analyzer!
86
+ write_envelope(stdout, analyzer_version, collect_reports)
87
+ 0
88
+ end
89
+
90
+ def require_project_file(cwd, section)
91
+ path = File.join(cwd, "config", "#{section}.rb")
92
+ raise LoadError, "Rails #{section} file not found at #{path}" unless File.file?(path)
93
+
94
+ require path
95
+ end
96
+
97
+ def rails_application
98
+ return Rails.application if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
99
+
100
+ raise LoadError, "Rails.application is unavailable after loading config/environment.rb"
101
+ end
102
+
103
+ def validate_analyzer_version!(spec)
104
+ raise LoadError, "database_consistency gem did not register a loaded gemspec" unless spec
105
+ return if SUPPORTED_ANALYZER_VERSION.satisfied_by?(spec.version)
106
+
107
+ raise LoadError,
108
+ "database_consistency #{spec.version} is unsupported; install ~> 3.0.14 (>= 3.0.14, < 3.1)"
109
+ end
110
+
111
+ def ensure_no_rescued_checker_errors!
112
+ return if DatabaseConsistency::RescueError.empty?
113
+
114
+ raise "database_consistency rescued a checker error; see its diagnostic file in the project root"
115
+ end
116
+
117
+ def serialize_reports(reports)
118
+ reports.map { serialize_report(_1) }
119
+ end
120
+ end
121
+ end
122
+ end
123
+
124
+ exit QualityGate::DatabaseConsistencyRunner.run if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require_relative "config"
5
+ require_relative "doctor_report"
6
+ require_relative "doctor_launchers"
7
+ require_relative "doctor_git"
8
+ require_relative "doctor_coverage"
9
+ require_relative "doctor_hooks"
10
+
11
+ module QualityGate
12
+ class DoctorRuntimeCheck
13
+ def call
14
+ defined?(Bundler::VERSION) ? bundled_check : unbundled_check
15
+ rescue StandardError
16
+ check("unchecked", "Ruby and Bundler runtime context could not be fully inspected.")
17
+ end
18
+
19
+ private
20
+
21
+ def bundled_check
22
+ context = ENV["BUNDLE_GEMFILE"] || "default bundle context"
23
+ message = "#{ruby_message} Bundler #{Bundler::VERSION} is loaded (#{context}); no application code was run."
24
+ check("ready", message)
25
+ end
26
+
27
+ def unbundled_check
28
+ message = "#{ruby_message} Bundler is not loaded; invoke through bundle exec to inspect bundle context."
29
+ check("unchecked", message)
30
+ end
31
+
32
+ def ruby_message = "Ruby #{RUBY_VERSION} at #{RbConfig.ruby}."
33
+
34
+ def check(status, message)
35
+ DoctorReport.check(id: "runtime", status:, message:)
36
+ end
37
+ end
38
+
39
+ class DoctorConfiguration
40
+ def initialize(config, registry:, dir:)
41
+ @config = config
42
+ @registry = registry
43
+ @dir = dir
44
+ end
45
+
46
+ def checks
47
+ [configuration_check, *DoctorAdapterChecks.new(config:, registry:, dir:).call]
48
+ end
49
+
50
+ def self.failure_checks
51
+ [blocked_config_check, configured_default_checks]
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :config, :registry, :dir
57
+
58
+ def configuration_check
59
+ keys = config.unknown_keys
60
+ warning = keys.any?
61
+ status = warning ? "warning" : "ready"
62
+ message = configuration_message(keys, warning)
63
+ DoctorReport.check(id: "configuration", status:, message:)
64
+ end
65
+
66
+ def configuration_message(keys, warning)
67
+ return "Configuration loaded; application checks were not run." unless warning
68
+
69
+ "Configuration loaded with unknown keys: #{keys.join(", ")}; review .quality_gate.yml."
70
+ end
71
+
72
+ def self.blocked_config_check
73
+ message = "Could not load .quality_gate.yml; fix its syntax or values before rerunning Doctor."
74
+ DoctorReport.check(id: "configuration", status: "blocked", message:)
75
+ end
76
+
77
+ def self.configured_default_checks
78
+ message = "Configured adapters were not inspected because configuration could not be loaded."
79
+ DoctorReport.check(id: "configured_checks", status: "unchecked", message:)
80
+ end
81
+ private_class_method :blocked_config_check, :configured_default_checks
82
+ end
83
+
84
+ class DoctorAdapterChecks
85
+ GATES = %i[fast verify audit].freeze
86
+
87
+ def initialize(config:, registry:, dir:)
88
+ @config = config
89
+ @registry = registry
90
+ @launchers = DoctorLaunchers.new(dir:)
91
+ end
92
+
93
+ def call
94
+ GATES.flat_map { checks_for_gate(_1) }
95
+ end
96
+
97
+ private
98
+
99
+ attr_reader :config, :launchers, :registry
100
+
101
+ def checks_for_gate(gate)
102
+ names = config.fetch(:adapters).fetch(gate)
103
+ return [empty_gate_check(gate)] if names.empty?
104
+
105
+ names.map { adapter_check(gate, _1) }
106
+ end
107
+
108
+ def adapter_check(gate, name)
109
+ adapter_class = registry.fetch(name) { return unknown_adapter(gate, name) }
110
+ return simplecov_check(gate) if name == "simplecov"
111
+
112
+ inspect_adapter(gate, name, adapter_class)
113
+ rescue StandardError
114
+ blocked_adapter(gate, name, "Could not safely inspect this adapter's timeout or argv configuration.")
115
+ end
116
+
117
+ def inspect_adapter(gate, name, adapter_class)
118
+ adapter = adapter_class.new(config:, files: [])
119
+ return invalid_timeout_check(gate, name) unless valid_timeout?(adapter.timeout)
120
+
121
+ argv = name == "undercover" ? ["undercover"] : adapter.command
122
+ launchers.check(adapter: name, gate:, argv:)
123
+ end
124
+
125
+ def invalid_timeout_check(gate, name)
126
+ blocked_adapter(gate, name, "Adapter timeout must be a positive Integer.")
127
+ end
128
+
129
+ def simplecov_check(gate)
130
+ return blocked_adapter(gate, "simplecov", threshold_message) unless coverage_budget?
131
+
132
+ ready_simplecov_check(gate)
133
+ end
134
+
135
+ def threshold_message
136
+ "SimpleCov requires a configured coverage threshold; add " \
137
+ "coverage.minimum_line and/or coverage.minimum_branch."
138
+ end
139
+
140
+ def ready_simplecov_check(gate)
141
+ message = "SimpleCov is internal and has a configured threshold; no external launcher is required."
142
+ DoctorReport.check(id: "command.#{gate}.simplecov", status: "ready", message:)
143
+ end
144
+
145
+ def coverage_budget?
146
+ coverage = config.fetch(:coverage)
147
+ coverage.is_a?(Hash) && %i[minimum_line minimum_branch].any? { coverage.key?(_1) }
148
+ end
149
+
150
+ def valid_timeout?(timeout)
151
+ timeout.is_a?(Integer) && timeout.positive?
152
+ end
153
+
154
+ def empty_gate_check(gate)
155
+ message = "No adapters are configured for this gate."
156
+ DoctorReport.check(id: "command.#{gate}", status: "not_applicable", message:)
157
+ end
158
+
159
+ def unknown_adapter(gate, name)
160
+ blocked_adapter(gate, name, "Unknown adapter #{name}; remove it or use a registered adapter.")
161
+ end
162
+
163
+ def blocked_adapter(gate, name, message)
164
+ DoctorReport.check(id: "command.#{gate}.#{name}", status: "blocked", message:)
165
+ end
166
+ end
167
+
168
+ # Coordinates read-only runtime, configuration, and launch probes.
169
+ class Doctor
170
+ def initialize(dir:, registry:)
171
+ @dir = File.expand_path(dir)
172
+ @registry = registry
173
+ end
174
+
175
+ def call
176
+ DoctorReport.new(checks: checks)
177
+ rescue ConfigError
178
+ DoctorReport.new(checks: [runtime_check, *DoctorConfiguration.failure_checks])
179
+ rescue StandardError
180
+ DoctorReport.new(checks: [runtime_check, doctor_failure_check])
181
+ end
182
+
183
+ private
184
+
185
+ attr_reader :dir, :registry
186
+
187
+ def checks
188
+ config = Config.load(dir:)
189
+ configuration_checks = DoctorConfiguration.new(config, registry:, dir:).checks
190
+ [runtime_check, *configuration_checks, *probe_checks(config)]
191
+ end
192
+
193
+ def probe_checks(config)
194
+ safe_probe("comparison") { DoctorGit.new(dir:, config:).call } +
195
+ safe_probe("coverage") { DoctorCoverage.new(dir:, config:).call } +
196
+ safe_probe("hooks") { DoctorHooks.new(dir:).call }
197
+ end
198
+
199
+ def safe_probe(id)
200
+ yield
201
+ rescue StandardError
202
+ message = "This bounded observation could not be completed; inspect its local setup."
203
+ [DoctorReport.check(id:, status: "unchecked", message:)]
204
+ end
205
+
206
+ def runtime_check
207
+ DoctorRuntimeCheck.new.call
208
+ end
209
+
210
+ def doctor_failure_check
211
+ message = "Doctor could not complete its bounded preflight checks."
212
+ DoctorReport.check(id: "doctor", status: "blocked", message:)
213
+ end
214
+ end
215
+
216
+ private_constant :DoctorRuntimeCheck, :DoctorConfiguration, :DoctorAdapterChecks
217
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QualityGate
4
+ # Reads a small regular file without following links or blocking on special files.
5
+ class DoctorBoundedFile
6
+ Result = Data.define(:status, :contents)
7
+ MAX_BYTES = 1024 * 1024
8
+
9
+ def self.read(path:, max_bytes: MAX_BYTES)
10
+ new(path:, max_bytes:).read
11
+ end
12
+
13
+ def initialize(path:, max_bytes:)
14
+ @path = path
15
+ @max_bytes = max_bytes
16
+ end
17
+
18
+ def read
19
+ read_expected_file
20
+ rescue SystemCallError, IOError => e
21
+ read_error(e)
22
+ end
23
+
24
+ private
25
+
26
+ def read_expected_file
27
+ expected = File.lstat(@path)
28
+ return Result.new(:unsafe, nil) unless expected.file?
29
+
30
+ read_open_file(expected)
31
+ end
32
+
33
+ def read_error(error)
34
+ status = error.is_a?(Errno::ENOENT) ? :missing : :unreadable
35
+ Result.new(status, nil)
36
+ end
37
+
38
+ def read_flags
39
+ %i[NONBLOCK NOFOLLOW].reduce(File::RDONLY) do |flags, name|
40
+ File.const_defined?(name) ? flags | File.const_get(name) : flags
41
+ end
42
+ end
43
+
44
+ def read_open_file(expected)
45
+ File.open(@path, read_flags) { |io| read_checked_io(io, expected) }
46
+ end
47
+
48
+ def read_checked_io(io, expected)
49
+ return Result.new(:unsafe, nil) unless same_file?(io.stat, expected, File.lstat(@path))
50
+ return Result.new(:oversized, nil) if io.stat.size > @max_bytes
51
+
52
+ read_contents(io, expected)
53
+ end
54
+
55
+ def read_contents(io, expected)
56
+ bytes = io.read(@max_bytes) || "".b
57
+ validate_contents(io, expected, bytes)
58
+ end
59
+
60
+ def validate_contents(io, expected, bytes)
61
+ opened = io.stat
62
+ return Result.new(:unsafe, nil) unless same_file?(opened, expected, File.lstat(@path))
63
+ return Result.new(:oversized, nil) if opened.size > @max_bytes || bytes.bytesize > @max_bytes
64
+
65
+ Result.new(:ok, bytes.force_encoding(Encoding::UTF_8).freeze)
66
+ end
67
+
68
+ def same_file?(opened, expected, current)
69
+ [opened, expected, current].all?(&:file?) &&
70
+ [opened.dev, opened.ino] == [expected.dev, expected.ino] &&
71
+ [opened.dev, opened.ino] == [current.dev, current.ino]
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module QualityGate
6
+ # Parses Doctor-specific options and renders a read-only preflight report.
7
+ class DoctorCommand
8
+ HELP = "Usage: quality_gate doctor [--format text|json] [--help]\n" \
9
+ "Read-only preflight; configured gates and application code are not run.\n" \
10
+ "Exit status: 0 ready, 1 warning or unchecked, 2 blocked or input/output failure."
11
+
12
+ def initialize(dir:, registry:)
13
+ @dir = dir
14
+ @registry = registry
15
+ end
16
+
17
+ def run(arguments, stdout:, stderr:)
18
+ run_options(parse(arguments), stdout:, stderr:)
19
+ rescue StandardError => e
20
+ render_failure(failure_check("input", e), requested_format(arguments), stdout:, stderr:)
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :dir, :registry
26
+
27
+ def parse(arguments)
28
+ options = { format: "text", help: false }
29
+ remaining = arguments.dup
30
+ parser(options).parse!(remaining)
31
+ reject_positionals(remaining)
32
+ options
33
+ end
34
+
35
+ def reject_positionals(arguments)
36
+ raise OptionParser::InvalidOption, arguments.join(" ") unless arguments.empty?
37
+ end
38
+
39
+ def run_options(options, stdout:, stderr:)
40
+ return print_help(stdout, stderr) if options.fetch(:help)
41
+
42
+ run_report(options, stdout)
43
+ rescue StandardError => e
44
+ render_failure(failure_check("doctor", e), options.fetch(:format), stdout:, stderr:)
45
+ end
46
+
47
+ def run_report(options, stdout)
48
+ report = Doctor.new(dir:, registry:).call
49
+ Reporters::Doctor.new(io: stdout).call(report, format: options.fetch(:format))
50
+ report.exit_code
51
+ end
52
+
53
+ def parser(options)
54
+ OptionParser.new(HELP) { register_options(_1, options) }
55
+ end
56
+
57
+ def register_options(parser, options)
58
+ parser.on("--format FORMAT") { |format| select_format(format, options) }
59
+ parser.on("-h", "--help") { options[:help] = true }
60
+ end
61
+
62
+ def select_format(format, options)
63
+ raise OptionParser::InvalidArgument, format unless %w[text json].include?(format)
64
+
65
+ options[:format] = format
66
+ end
67
+
68
+ def requested_format(arguments)
69
+ arguments.take_while { _1 != "--" }.each_with_index.filter_map do |argument, index|
70
+ format_option(argument, arguments[index + 1])
71
+ end.last || "text"
72
+ end
73
+
74
+ def format_option(argument, next_argument)
75
+ return next_argument if argument == "--format"
76
+
77
+ argument.delete_prefix("--format=") if argument.start_with?("--format=")
78
+ end
79
+
80
+ def failure_check(id, error)
81
+ DoctorReport.check(id:, status: "blocked", message: safe_message(error))
82
+ end
83
+
84
+ def render_failure(check, format, stdout:, stderr:)
85
+ return report_json_failure(check, stdout, stderr) if format == "json"
86
+
87
+ write_error(stderr, check.fetch("message"))
88
+ ExitCode::TOOL_FAILURE
89
+ end
90
+
91
+ def report_json_failure(check, stdout, stderr)
92
+ write_json_failure(check, stdout)
93
+ rescue StandardError => e
94
+ write_error(stderr, e)
95
+ ExitCode::TOOL_FAILURE
96
+ end
97
+
98
+ def write_json_failure(check, stdout)
99
+ report = DoctorReport.new(checks: [check])
100
+ Reporters::Doctor.new(io: stdout).call(report, format: "json")
101
+ ExitCode::TOOL_FAILURE
102
+ end
103
+
104
+ def safe_message(error)
105
+ message = error.is_a?(String) ? error : error.message
106
+ sanitize_message(message)
107
+ rescue StandardError
108
+ "quality_gate doctor failed"
109
+ end
110
+
111
+ def sanitize_message(message)
112
+ message.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�")
113
+ .gsub(/[[:cntrl:]\u2028\u2029]+/, " ").strip
114
+ end
115
+
116
+ def write_error(io, error)
117
+ io.puts "Error: #{safe_message(error)}"
118
+ rescue StandardError
119
+ false
120
+ end
121
+
122
+ def print_help(stdout, stderr)
123
+ stdout.puts parser({}).to_s
124
+ ExitCode::CLEAN
125
+ rescue StandardError => e
126
+ write_error(stderr, e)
127
+ ExitCode::TOOL_FAILURE
128
+ end
129
+ end
130
+ end