quality_gate 0.2.1 → 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 +26 -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 -96
- 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 +44 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QualityGate
|
|
4
|
+
# Holds bounded preflight observations independently of gate findings.
|
|
5
|
+
class DoctorReport
|
|
6
|
+
STATUSES = %w[ready warning blocked unchecked not_applicable].map!(&:freeze).freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :checks, :summary
|
|
9
|
+
|
|
10
|
+
def self.check(id:, status:, message:)
|
|
11
|
+
normalized_status = status.to_s
|
|
12
|
+
raise ArgumentError, "unknown Doctor status" unless STATUSES.include?(normalized_status)
|
|
13
|
+
|
|
14
|
+
{ "id" => id.to_s.dup.freeze, "status" => normalized_status.dup.freeze,
|
|
15
|
+
"message" => message.to_s.dup.freeze }.freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(checks:)
|
|
19
|
+
@checks = checks.map { normalize_check(_1) }.freeze
|
|
20
|
+
@summary = STATUSES.to_h do |status|
|
|
21
|
+
[status, @checks.count { |check| check.fetch("status") == status }]
|
|
22
|
+
end.freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def exit_code
|
|
26
|
+
return 2 if summary.fetch("blocked").positive?
|
|
27
|
+
return 1 if summary.fetch("warning").positive? || summary.fetch("unchecked").positive?
|
|
28
|
+
|
|
29
|
+
0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def normalize_check(check)
|
|
35
|
+
self.class.check(id: check.fetch("id"), status: check.fetch("status"), message: check.fetch("message"))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -16,7 +16,7 @@ module QualityGate
|
|
|
16
16
|
|
|
17
17
|
def parse(arguments)
|
|
18
18
|
reject_format!(arguments)
|
|
19
|
-
options = { profile: "ruby", skip_coverage: false, agents: false, pretend: false }
|
|
19
|
+
options = { profile: "ruby", skip_coverage: false, agents: false, codex: false, ci: false, pretend: false }
|
|
20
20
|
option_parser(options).parse!(arguments)
|
|
21
21
|
raise OptionParser::InvalidOption, arguments.join(" ") if arguments.any?
|
|
22
22
|
|
|
@@ -62,6 +62,8 @@ module QualityGate
|
|
|
62
62
|
def boolean_options(parser, options)
|
|
63
63
|
parser.on("--skip-coverage") { options[:skip_coverage] = true }
|
|
64
64
|
parser.on("--agents") { options[:agents] = true }
|
|
65
|
+
parser.on("--codex") { options[:codex] = true }
|
|
66
|
+
parser.on("--ci") { options[:ci] = true }
|
|
65
67
|
parser.on("--pretend") { options[:pretend] = true }
|
|
66
68
|
end
|
|
67
69
|
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "erb"
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require "json"
|
|
5
6
|
require "pathname"
|
|
6
|
-
require "
|
|
7
|
+
require "shellwords"
|
|
7
8
|
require "tempfile"
|
|
8
9
|
|
|
9
10
|
module QualityGate
|
|
@@ -19,6 +20,9 @@ module QualityGate
|
|
|
19
20
|
.claude/settings.json
|
|
20
21
|
CLAUDE.md
|
|
21
22
|
AGENTS.md
|
|
23
|
+
.codex/hooks.json
|
|
24
|
+
.codex/hooks/quality_gate_fast.rb
|
|
25
|
+
.codex/hooks/quality_gate_verify_stop.rb
|
|
22
26
|
].freeze
|
|
23
27
|
MARKER_START = "# quality_gate coverage — start"
|
|
24
28
|
MARKER_END = "# quality_gate coverage — end"
|
|
@@ -28,7 +32,10 @@ module QualityGate
|
|
|
28
32
|
AGENT_TEMPLATE_PATHS = {
|
|
29
33
|
".claude/hooks/quality_gate_fast.rb" => "quality_gate_fast.rb.tt",
|
|
30
34
|
".claude/hooks/quality_gate_verify_stop.rb" => "quality_gate_verify_stop.rb.tt",
|
|
31
|
-
".claude/settings.json" => "claude_settings.json.tt"
|
|
35
|
+
".claude/settings.json" => "claude_settings.json.tt",
|
|
36
|
+
".codex/hooks.json" => "codex_hooks.json.tt",
|
|
37
|
+
".codex/hooks/quality_gate_fast.rb" => "codex_fast.rb.tt",
|
|
38
|
+
".codex/hooks/quality_gate_verify_stop.rb" => "codex_verify_stop.rb.tt"
|
|
32
39
|
}.freeze
|
|
33
40
|
AGENT_CONTRACT_PATHS = %w[CLAUDE.md AGENTS.md].freeze
|
|
34
41
|
PRIOR_CLAUDE_SETTINGS = [
|
|
@@ -52,6 +59,25 @@ module QualityGate
|
|
|
52
59
|
}
|
|
53
60
|
JSON
|
|
54
61
|
].freeze
|
|
62
|
+
PRIOR_CODEX_HOOKS = lambda do |root|
|
|
63
|
+
JSON.pretty_generate(
|
|
64
|
+
"hooks" => {
|
|
65
|
+
"Stop" => [
|
|
66
|
+
{
|
|
67
|
+
"hooks" => [
|
|
68
|
+
{
|
|
69
|
+
"type" => "command",
|
|
70
|
+
"command" => Shellwords.join(
|
|
71
|
+
["ruby", File.expand_path(".codex/hooks/quality_gate_verify_stop.rb", root)]
|
|
72
|
+
),
|
|
73
|
+
"timeout" => 600
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
).b << "\n".b
|
|
80
|
+
end
|
|
55
81
|
STRONG_MIGRATIONS_PROVENANCE = "# Generated by Quality Gate."
|
|
56
82
|
HOOK_MODE = 0o755
|
|
57
83
|
DEFAULT_AGENT_ARTIFACT_MODE = 0o644
|
|
@@ -63,7 +89,6 @@ module QualityGate
|
|
|
63
89
|
class Unsupported < StandardError; end
|
|
64
90
|
|
|
65
91
|
IMPORTER_MUTEX = Mutex.new
|
|
66
|
-
BUNDLED_REQUIRE_MUTEX = Mutex.new
|
|
67
92
|
LINK_FLAGS = 0
|
|
68
93
|
UNLINK_FLAGS = 0
|
|
69
94
|
O_DIRECTORY = File.const_defined?(:DIRECTORY) ? File::DIRECTORY : 0
|
|
@@ -169,87 +194,10 @@ module QualityGate
|
|
|
169
194
|
end
|
|
170
195
|
|
|
171
196
|
def load_fiddle_importer!
|
|
172
|
-
return if defined?(Fiddle::Importer)
|
|
173
|
-
return load_bundled_fiddle_importer! if ruby4_bundle_fiddle?
|
|
174
|
-
|
|
175
197
|
require "fiddle/import"
|
|
176
198
|
rescue LoadError => e
|
|
177
199
|
raise Unsupported, e.message
|
|
178
200
|
end
|
|
179
|
-
|
|
180
|
-
def ruby4_bundle_fiddle?
|
|
181
|
-
RUBY_VERSION.start_with?("4.") && defined?(Bundler)
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def load_bundled_fiddle_importer!
|
|
185
|
-
with_bundled_fiddle_require { load(fiddle_feature_paths.fetch("fiddle/import")) }
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def with_bundled_fiddle_require
|
|
189
|
-
BUNDLED_REQUIRE_MUTEX.synchronize do
|
|
190
|
-
original_require = Kernel.instance_method(:require)
|
|
191
|
-
original_verbose = $VERBOSE
|
|
192
|
-
feature_paths = fiddle_feature_paths
|
|
193
|
-
$VERBOSE = nil
|
|
194
|
-
Kernel.module_eval do
|
|
195
|
-
define_method(:require) do |feature|
|
|
196
|
-
path = feature_paths[feature]
|
|
197
|
-
if path
|
|
198
|
-
return false if $LOADED_FEATURES.include?(path)
|
|
199
|
-
|
|
200
|
-
loaded = path.end_with?(".rb") ? load(path) : original_require.bind_call(self, path)
|
|
201
|
-
$LOADED_FEATURES << path unless $LOADED_FEATURES.include?(path)
|
|
202
|
-
loaded || true
|
|
203
|
-
else
|
|
204
|
-
original_require.bind_call(self, feature)
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
end
|
|
208
|
-
yield
|
|
209
|
-
ensure
|
|
210
|
-
Kernel.module_eval { define_method(:require, original_require) } if original_require
|
|
211
|
-
$VERBOSE = original_verbose
|
|
212
|
-
end
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
def fiddle_feature_paths
|
|
216
|
-
root = bundled_fiddle_root
|
|
217
|
-
{
|
|
218
|
-
"fiddle" => File.join(root, "fiddle.rb"),
|
|
219
|
-
"fiddle.so" => File.join(bundled_fiddle_extension_root, "fiddle.#{RbConfig::CONFIG.fetch("DLEXT")}"),
|
|
220
|
-
"fiddle/closure" => File.join(root, "fiddle", "closure.rb"),
|
|
221
|
-
"fiddle/function" => File.join(root, "fiddle", "function.rb"),
|
|
222
|
-
"fiddle/version" => File.join(root, "fiddle", "version.rb"),
|
|
223
|
-
"fiddle/import" => File.join(root, "fiddle", "import.rb"),
|
|
224
|
-
"fiddle/struct" => File.join(root, "fiddle", "struct.rb"),
|
|
225
|
-
"fiddle/cparser" => File.join(root, "fiddle", "cparser.rb"),
|
|
226
|
-
"fiddle/types" => File.join(root, "fiddle", "types.rb"),
|
|
227
|
-
"fiddle/value" => File.join(root, "fiddle", "value.rb"),
|
|
228
|
-
"fiddle/pack" => File.join(root, "fiddle", "pack.rb")
|
|
229
|
-
}
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
def bundled_fiddle_root
|
|
233
|
-
Dir[File.join(RbConfig::CONFIG["prefix"], "lib/ruby/gems/**/gems/fiddle-*/lib")].max or
|
|
234
|
-
raise Unsupported, "fiddle support is unavailable"
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
def bundled_fiddle_extension_root
|
|
238
|
-
root = bundled_fiddle_root
|
|
239
|
-
extension = File.join(root, "fiddle.#{RbConfig::CONFIG.fetch("DLEXT")}")
|
|
240
|
-
return root if File.file?(extension)
|
|
241
|
-
|
|
242
|
-
gem_name = File.basename(File.dirname(root))
|
|
243
|
-
gem_home = File.expand_path("../../..", root)
|
|
244
|
-
extension_api = if defined?(Gem) && Gem.respond_to?(:extension_api_version)
|
|
245
|
-
Gem.extension_api_version
|
|
246
|
-
else
|
|
247
|
-
RbConfig::CONFIG.fetch("ruby_version")
|
|
248
|
-
end
|
|
249
|
-
pattern = File.join(gem_home, "extensions", RbConfig::CONFIG.fetch("arch"), extension_api, gem_name)
|
|
250
|
-
native_root = pattern if File.file?(File.join(pattern, "fiddle.#{RbConfig::CONFIG.fetch("DLEXT")}"))
|
|
251
|
-
native_root or raise Unsupported, "fiddle native extension is unavailable"
|
|
252
|
-
end
|
|
253
201
|
end
|
|
254
202
|
PreparedTempfile = Data.define(
|
|
255
203
|
:file,
|
|
@@ -272,7 +220,7 @@ module QualityGate
|
|
|
272
220
|
private_constant :PreparedTempfile
|
|
273
221
|
private_constant :DirectoryOperations
|
|
274
222
|
private_constant :AGENT_TEMPLATE_PATHS, :AGENT_CONTRACT_PATHS
|
|
275
|
-
private_constant :PRIOR_CLAUDE_SETTINGS
|
|
223
|
+
private_constant :PRIOR_CLAUDE_SETTINGS, :PRIOR_CODEX_HOOKS
|
|
276
224
|
private_constant :AGENT_CONTRACT_START, :AGENT_CONTRACT_END
|
|
277
225
|
private_constant :HOOK_MODE, :DEFAULT_AGENT_ARTIFACT_MODE, :TEMPFILE_MODE
|
|
278
226
|
|
|
@@ -303,7 +251,7 @@ module QualityGate
|
|
|
303
251
|
relative_path = test_helper_path
|
|
304
252
|
path = safe_destination_path(relative_path)
|
|
305
253
|
return unless path
|
|
306
|
-
return skip_missing_test_helper unless File.file?(path)
|
|
254
|
+
return skip_missing_test_helper(relative_path) unless File.file?(path)
|
|
307
255
|
|
|
308
256
|
install_coverage(path, relative_path)
|
|
309
257
|
end
|
|
@@ -311,22 +259,25 @@ module QualityGate
|
|
|
311
259
|
def create_agent_integration
|
|
312
260
|
return unless invoke_behavior?
|
|
313
261
|
|
|
314
|
-
unless options[:agents]
|
|
315
|
-
record(
|
|
262
|
+
unless options[:agents] || options[:codex]
|
|
263
|
+
record(
|
|
264
|
+
:skipped,
|
|
265
|
+
"agent integration (pass --agents for Claude hooks or --codex for Codex patch feedback and Stop verification)"
|
|
266
|
+
)
|
|
316
267
|
return
|
|
317
268
|
end
|
|
318
269
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
"quality_gate_verify_stop.rb.tt",
|
|
322
|
-
".claude/hooks/quality_gate_verify_stop.rb",
|
|
323
|
-
mode: HOOK_MODE
|
|
324
|
-
)
|
|
325
|
-
install_template("claude_settings.json.tt", ".claude/settings.json")
|
|
326
|
-
install_agent_contract("CLAUDE.md")
|
|
270
|
+
install_claude_integration if options[:agents]
|
|
271
|
+
install_codex_integration if options[:codex]
|
|
327
272
|
install_agent_contract("AGENTS.md")
|
|
328
273
|
end
|
|
329
274
|
|
|
275
|
+
def create_ci_workflow
|
|
276
|
+
return unless invoke_behavior? && options[:ci]
|
|
277
|
+
|
|
278
|
+
install_template("quality_gate_workflow.yml.tt", ".github/workflows/quality_gate.yml")
|
|
279
|
+
end
|
|
280
|
+
|
|
330
281
|
def print_summary
|
|
331
282
|
return print_unsupported_behavior unless invoke_behavior?
|
|
332
283
|
|
|
@@ -340,10 +291,31 @@ module QualityGate
|
|
|
340
291
|
|
|
341
292
|
private
|
|
342
293
|
|
|
294
|
+
def install_claude_integration
|
|
295
|
+
install_template("quality_gate_fast.rb.tt", ".claude/hooks/quality_gate_fast.rb", mode: HOOK_MODE)
|
|
296
|
+
install_template(
|
|
297
|
+
"quality_gate_verify_stop.rb.tt",
|
|
298
|
+
".claude/hooks/quality_gate_verify_stop.rb",
|
|
299
|
+
mode: HOOK_MODE
|
|
300
|
+
)
|
|
301
|
+
install_template("claude_settings.json.tt", ".claude/settings.json")
|
|
302
|
+
install_agent_contract("CLAUDE.md")
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def install_codex_integration
|
|
306
|
+
install_template("codex_fast.rb.tt", ".codex/hooks/quality_gate_fast.rb", mode: HOOK_MODE)
|
|
307
|
+
install_template("codex_hooks.json.tt", ".codex/hooks.json")
|
|
308
|
+
install_template("codex_verify_stop.rb.tt", ".codex/hooks/quality_gate_verify_stop.rb", mode: HOOK_MODE)
|
|
309
|
+
end
|
|
310
|
+
|
|
343
311
|
def invoke_behavior?
|
|
344
312
|
behavior == :invoke
|
|
345
313
|
end
|
|
346
314
|
|
|
315
|
+
def agent_option?(name)
|
|
316
|
+
options.fetch(name, options.fetch(name.to_s, false))
|
|
317
|
+
end
|
|
318
|
+
|
|
347
319
|
def print_unsupported_behavior
|
|
348
320
|
say "Automatic removal is not supported. See the README installation undo instructions for manual removal."
|
|
349
321
|
end
|
|
@@ -363,7 +335,7 @@ module QualityGate
|
|
|
363
335
|
existing = existing_file.fetch(:content)
|
|
364
336
|
if existing == content.b
|
|
365
337
|
record_existing_template(path, relative_path, existing:, mode:, status: existing_file.fetch(:status))
|
|
366
|
-
elsif prior_claude_settings?(relative_path, existing)
|
|
338
|
+
elsif prior_claude_settings?(relative_path, existing) || prior_codex_hooks?(relative_path, existing)
|
|
367
339
|
return record_pretend_skip(relative_path) if options[:pretend]
|
|
368
340
|
|
|
369
341
|
replace_content(path, content, relative_path, expected: existing_file)
|
|
@@ -381,6 +353,10 @@ module QualityGate
|
|
|
381
353
|
relative_path == ".claude/settings.json" && PRIOR_CLAUDE_SETTINGS.include?(content)
|
|
382
354
|
end
|
|
383
355
|
|
|
356
|
+
def prior_codex_hooks?(relative_path, content)
|
|
357
|
+
relative_path == ".codex/hooks.json" && content == PRIOR_CODEX_HOOKS.call(destination_root).b
|
|
358
|
+
end
|
|
359
|
+
|
|
384
360
|
def install_agent_contract(relative_path)
|
|
385
361
|
path = safe_destination_path(relative_path)
|
|
386
362
|
return unless path
|
|
@@ -656,9 +632,9 @@ module QualityGate
|
|
|
656
632
|
markers
|
|
657
633
|
end
|
|
658
634
|
|
|
659
|
-
def skip_missing_test_helper
|
|
660
|
-
say "Warning: Minitest coverage wiring skipped;
|
|
661
|
-
record(:skipped, "
|
|
635
|
+
def skip_missing_test_helper(relative_path)
|
|
636
|
+
say "Warning: Minitest coverage wiring skipped; #{relative_path} is missing."
|
|
637
|
+
record(:skipped, "#{relative_path} (missing; Minitest coverage wiring skipped)")
|
|
662
638
|
end
|
|
663
639
|
|
|
664
640
|
def skip_existing_simplecov(relative_path)
|
|
@@ -15,6 +15,8 @@ module QualityGate
|
|
|
15
15
|
test_helper
|
|
16
16
|
test_command
|
|
17
17
|
agents
|
|
18
|
+
codex
|
|
19
|
+
ci
|
|
18
20
|
pretend
|
|
19
21
|
skip_coverage
|
|
20
22
|
].freeze
|
|
@@ -25,6 +27,7 @@ module QualityGate
|
|
|
25
27
|
create_initializers
|
|
26
28
|
inject_coverage
|
|
27
29
|
create_agent_integration
|
|
30
|
+
create_ci_workflow
|
|
28
31
|
].freeze
|
|
29
32
|
|
|
30
33
|
attr_reader :destination_root, :stdout, :profile
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../doctor_report"
|
|
5
|
+
|
|
6
|
+
module QualityGate
|
|
7
|
+
module Reporters
|
|
8
|
+
# Renders Doctor's preflight checks as text or a stable JSON envelope.
|
|
9
|
+
class Doctor
|
|
10
|
+
def initialize(io:)
|
|
11
|
+
@io = io
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(report, format:)
|
|
15
|
+
case format
|
|
16
|
+
when "json" then io.puts(JSON.generate(json_payload(report)))
|
|
17
|
+
when "text" then print_text(report)
|
|
18
|
+
else raise ArgumentError, "Doctor format must be text or json"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
attr_reader :io
|
|
25
|
+
|
|
26
|
+
def json_payload(report)
|
|
27
|
+
{
|
|
28
|
+
"scope" => "preflight",
|
|
29
|
+
"checks" => sanitized_checks(report),
|
|
30
|
+
"summary" => report.summary
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def sanitized_checks(report)
|
|
35
|
+
report.checks.map do |check|
|
|
36
|
+
check.transform_values { |value| FieldSanitizer.for_json(value) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def print_text(report)
|
|
41
|
+
report.checks.each do |check|
|
|
42
|
+
io.puts [check.fetch("id"), check.fetch("status"), check.fetch("message")]
|
|
43
|
+
.map { FieldSanitizer.for_text(_1) }.join(" ")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
io.puts "Preflight: #{summary_text(report.summary)}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def summary_text(summary)
|
|
50
|
+
DoctorReport::STATUSES.map do |status|
|
|
51
|
+
label = status == "not_applicable" ? "not applicable" : status
|
|
52
|
+
"#{summary.fetch(status)} #{label}"
|
|
53
|
+
end.join(", ")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -19,7 +19,7 @@ module QualityGate
|
|
|
19
19
|
attr_reader :io
|
|
20
20
|
|
|
21
21
|
def payload_for(result)
|
|
22
|
-
{
|
|
22
|
+
payload = {
|
|
23
23
|
"checks" => result.checks.map { serialize_check(_1) },
|
|
24
24
|
"findings" => result.findings.map { serialize_finding(_1) },
|
|
25
25
|
"summary" => {
|
|
@@ -28,6 +28,23 @@ module QualityGate
|
|
|
28
28
|
"failed_tools" => result.failed_tools.map { FieldSanitizer.for_json(_1) }
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
+
add_baseline(payload, result.baseline)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def add_baseline(payload, baseline)
|
|
35
|
+
payload["baseline"] = serialize_baseline(baseline) if baseline
|
|
36
|
+
payload
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def serialize_baseline(baseline)
|
|
40
|
+
{
|
|
41
|
+
"mode" => baseline.fetch(:mode),
|
|
42
|
+
"status" => baseline.fetch(:status),
|
|
43
|
+
"accepted_count" => baseline.fetch(:accepted_count),
|
|
44
|
+
"removed_count" => baseline.fetch(:removed_count),
|
|
45
|
+
"written" => baseline.fetch(:written),
|
|
46
|
+
"accepted_findings" => baseline.fetch(:accepted_findings).map { serialize_finding(_1) }
|
|
47
|
+
}
|
|
31
48
|
end
|
|
32
49
|
|
|
33
50
|
def serialize_finding(finding)
|
|
@@ -19,7 +19,17 @@ module QualityGate
|
|
|
19
19
|
attr_reader :io
|
|
20
20
|
|
|
21
21
|
def sections_for(result)
|
|
22
|
-
[
|
|
22
|
+
[
|
|
23
|
+
summary_heading(result), baseline_section(result.baseline),
|
|
24
|
+
checks_table(result.checks), findings_section(result.findings)
|
|
25
|
+
].compact
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def baseline_section(baseline)
|
|
29
|
+
return unless baseline
|
|
30
|
+
|
|
31
|
+
"Baseline **#{baseline.fetch(:mode)}**: #{baseline.fetch(:status)}, " \
|
|
32
|
+
"#{baseline.fetch(:accepted_count)} accepted, #{baseline.fetch(:removed_count)} removed."
|
|
23
33
|
end
|
|
24
34
|
|
|
25
35
|
def summary_heading(result)
|
|
@@ -10,18 +10,26 @@ module QualityGate
|
|
|
10
10
|
|
|
11
11
|
def call(result)
|
|
12
12
|
print_checks(result.checks)
|
|
13
|
-
|
|
14
|
-
result
|
|
15
|
-
io.puts(render_finding(finding))
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
io.puts("#{result.findings.length} findings, #{result.failed_tools.length} tool failures")
|
|
13
|
+
print_baseline(result.baseline) if result.baseline
|
|
14
|
+
print_findings_and_summary(result)
|
|
19
15
|
end
|
|
20
16
|
|
|
21
17
|
private
|
|
22
18
|
|
|
23
19
|
attr_reader :io
|
|
24
20
|
|
|
21
|
+
def print_findings_and_summary(result)
|
|
22
|
+
result.findings.each { |finding| io.puts(render_finding(finding)) }
|
|
23
|
+
io.puts("#{result.findings.length} findings, #{result.failed_tools.length} tool failures")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def print_baseline(baseline)
|
|
27
|
+
io.puts(
|
|
28
|
+
"Baseline #{baseline.fetch(:mode)}: #{baseline.fetch(:status)}, " \
|
|
29
|
+
"#{baseline.fetch(:accepted_count)} accepted, #{baseline.fetch(:removed_count)} removed"
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
25
33
|
def print_checks(checks)
|
|
26
34
|
return if checks.empty?
|
|
27
35
|
|
|
@@ -42,8 +42,8 @@ module QualityGate
|
|
|
42
42
|
raise ArgumentError, "#{name} must stay inside the project"
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
def command(value, framework)
|
|
46
|
-
command = value.nil? ?
|
|
45
|
+
def command(value, framework, defaults: DEFAULT_COMMANDS)
|
|
46
|
+
command = value.nil? ? defaults.fetch(framework) : parse_command(value)
|
|
47
47
|
validate_command!(command)
|
|
48
48
|
command.map(&:dup).freeze
|
|
49
49
|
end
|
|
@@ -167,4 +167,123 @@ module QualityGate
|
|
|
167
167
|
"test helper #{relative.inspect} does not exist; pass --skip-coverage or provide an existing helper"
|
|
168
168
|
end
|
|
169
169
|
end
|
|
170
|
+
|
|
171
|
+
# Resolves the Rails test helper and command while sharing the Ruby profile's
|
|
172
|
+
# validation for project-relative paths and shell-free argv.
|
|
173
|
+
class RailsProfile
|
|
174
|
+
DEFAULT_HELPERS = {
|
|
175
|
+
"minitest" => "test/test_helper.rb",
|
|
176
|
+
"rspec" => "spec/rails_helper.rb"
|
|
177
|
+
}.freeze
|
|
178
|
+
DEFAULT_COMMANDS = {
|
|
179
|
+
"minitest" => %w[bin/rails test].freeze,
|
|
180
|
+
"rspec" => %w[bundle exec rspec].freeze
|
|
181
|
+
}.freeze
|
|
182
|
+
TEMPLATE_MAP = { "quality_gate.yml.tt" => "rails_quality_gate.yml.tt" }.freeze
|
|
183
|
+
SUPPORTED_FRAMEWORKS = DEFAULT_HELPERS.keys.freeze
|
|
184
|
+
|
|
185
|
+
attr_reader :destination_root, :framework, :test_helper, :test_command
|
|
186
|
+
|
|
187
|
+
def initialize(destination_root:, options: {})
|
|
188
|
+
@destination_root = RubyProfileSupport.destination_root(destination_root)
|
|
189
|
+
@options = RubyProfileSupport.options(options)
|
|
190
|
+
resolve_settings
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def coverage? = @coverage
|
|
194
|
+
|
|
195
|
+
def missing_default_helper? = !!@missing_default_helper
|
|
196
|
+
|
|
197
|
+
def template_for(name)
|
|
198
|
+
TEMPLATE_MAP.fetch(name, name)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private
|
|
202
|
+
|
|
203
|
+
attr_reader :options
|
|
204
|
+
|
|
205
|
+
def resolve_settings
|
|
206
|
+
@coverage = !option(:skip_coverage)
|
|
207
|
+
@framework = resolve_framework
|
|
208
|
+
@test_helper = resolve_test_helper
|
|
209
|
+
@test_command = RubyProfileSupport.command(option(:test_command), framework, defaults: DEFAULT_COMMANDS)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def option(key)
|
|
213
|
+
options.fetch(key, nil)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def resolve_framework
|
|
217
|
+
explicit = option(:test_framework)
|
|
218
|
+
return validate_framework(explicit) if explicit
|
|
219
|
+
|
|
220
|
+
helper = option(:test_helper)
|
|
221
|
+
return framework_for_helper(helper) if helper
|
|
222
|
+
|
|
223
|
+
detected_framework
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def detected_framework
|
|
227
|
+
available = DEFAULT_HELPERS.select { |_name, path| helper_present?(path) }.keys
|
|
228
|
+
return available.first if available.one?
|
|
229
|
+
|
|
230
|
+
return missing_default_framework if available.empty?
|
|
231
|
+
|
|
232
|
+
raise ArgumentError, "both test helpers exist; pass --test-framework minitest or rspec"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def missing_default_framework
|
|
236
|
+
@missing_default_helper = true
|
|
237
|
+
"minitest"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def validate_framework(value)
|
|
241
|
+
selected = value.to_s
|
|
242
|
+
return selected if SUPPORTED_FRAMEWORKS.include?(selected)
|
|
243
|
+
|
|
244
|
+
raise ArgumentError, "test_framework must be minitest or rspec"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def framework_for_helper(value)
|
|
248
|
+
relative = RubyProfileSupport.relative_path(destination_root, value, "test_helper")
|
|
249
|
+
relative.start_with?("spec/") || relative == "spec" ? "rspec" : "minitest"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def resolve_test_helper
|
|
253
|
+
helper = option(:test_helper) || DEFAULT_HELPERS.fetch(framework)
|
|
254
|
+
relative = RubyProfileSupport.relative_path(destination_root, helper, "test_helper")
|
|
255
|
+
return relative unless coverage?
|
|
256
|
+
|
|
257
|
+
validate_test_helper!(relative)
|
|
258
|
+
relative
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def validate_test_helper!(relative)
|
|
262
|
+
path = helper_path_without_symlinks(relative)
|
|
263
|
+
raise ArgumentError, "test_helper must not pass through symlinks" unless path
|
|
264
|
+
return if File.file?(path) || missing_default_helper?
|
|
265
|
+
|
|
266
|
+
raise ArgumentError,
|
|
267
|
+
"test helper #{relative.inspect} does not exist; pass --skip-coverage or provide an existing helper"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def helper_present?(helper)
|
|
271
|
+
path = helper_path_without_symlinks(helper)
|
|
272
|
+
path && File.file?(path)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def helper_path_without_symlinks(helper)
|
|
276
|
+
return if symlink_in_path?(helper)
|
|
277
|
+
|
|
278
|
+
File.join(destination_root, helper)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def symlink_in_path?(helper)
|
|
282
|
+
path = destination_root
|
|
283
|
+
helper.split(File::SEPARATOR).any? do |component|
|
|
284
|
+
path = File.join(path, component)
|
|
285
|
+
File.symlink?(path)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
170
289
|
end
|