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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +292 -21
- data/docs/codex.md +37 -9
- data/docs/releasing.md +16 -4
- data/lib/generators/quality_gate/install/install_generator.rb +18 -3
- data/lib/generators/quality_gate/install/templates/agents_section.md.tt +13 -2
- data/lib/generators/quality_gate/install/templates/codex_fast.rb.tt +36 -0
- data/lib/generators/quality_gate/install/templates/codex_hooks.json.tt +1 -0
- data/lib/generators/quality_gate/install/templates/codex_verify_stop.rb.tt +28 -0
- data/lib/generators/quality_gate/install/templates/quality_gate.yml.tt +6 -0
- data/lib/generators/quality_gate/install/templates/quality_gate_fast.rb.tt +16 -3
- data/lib/generators/quality_gate/install/templates/quality_gate_workflow.yml.tt +28 -0
- data/lib/generators/quality_gate/install/templates/rails_quality_gate.yml.tt +45 -0
- data/lib/generators/quality_gate/install/templates/ruby_agents_section.md.tt +13 -2
- data/lib/generators/quality_gate/install/templates/ruby_quality_gate.yml.tt +5 -0
- data/lib/generators/quality_gate/install/templates/simplecov.rb.tt +1 -0
- data/lib/quality_gate/adapters/bundler_audit.rb +28 -3
- data/lib/quality_gate/adapters/database_consistency.rb +51 -0
- data/lib/quality_gate/adapters/database_consistency_report.rb +61 -0
- data/lib/quality_gate/adapters/database_consistency_report_item.rb +122 -0
- data/lib/quality_gate/adapters/debride.rb +67 -0
- data/lib/quality_gate/adapters/debride_report.rb +80 -0
- data/lib/quality_gate/adapters/herb.rb +303 -0
- data/lib/quality_gate/adapters/reek.rb +21 -2
- data/lib/quality_gate/adapters/rubocop.rb +23 -9
- data/lib/quality_gate/adapters/rubycritic.rb +74 -0
- data/lib/quality_gate/adapters/rubycritic_report.rb +112 -0
- data/lib/quality_gate/adapters/test_suite.rb +12 -1
- data/lib/quality_gate/baseline.rb +269 -0
- data/lib/quality_gate/baseline_run.rb +80 -0
- data/lib/quality_gate/cli.rb +129 -6
- data/lib/quality_gate/codex_fast_hook.rb +163 -0
- data/lib/quality_gate/codex_patch_files.rb +95 -0
- data/lib/quality_gate/codex_stop_hook.rb +102 -0
- data/lib/quality_gate/config.rb +20 -6
- data/lib/quality_gate/database_consistency_runner.rb +124 -0
- data/lib/quality_gate/doctor.rb +217 -0
- data/lib/quality_gate/doctor_bounded_file.rb +74 -0
- data/lib/quality_gate/doctor_command.rb +130 -0
- data/lib/quality_gate/doctor_coverage.rb +113 -0
- data/lib/quality_gate/doctor_git.rb +121 -0
- data/lib/quality_gate/doctor_hooks.rb +99 -0
- data/lib/quality_gate/doctor_launchers.rb +300 -0
- data/lib/quality_gate/doctor_path_lookup.rb +52 -0
- data/lib/quality_gate/doctor_report.rb +38 -0
- data/lib/quality_gate/init_command.rb +3 -1
- data/lib/quality_gate/installation.rb +72 -17
- data/lib/quality_gate/installer.rb +3 -0
- data/lib/quality_gate/reporters/doctor.rb +57 -0
- data/lib/quality_gate/reporters/json.rb +18 -1
- data/lib/quality_gate/reporters/markdown.rb +11 -1
- data/lib/quality_gate/reporters/text.rb +14 -6
- data/lib/quality_gate/ruby_profile.rb +121 -2
- data/lib/quality_gate/runner.rb +15 -17
- data/lib/quality_gate/version.rb +1 -1
- data/lib/quality_gate.rb +15 -0
- data/sig/quality_gate.rbs +117 -2
- metadata +30 -1
data/lib/quality_gate/config.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|