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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -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 -96
  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 +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 "rbconfig"
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(:skipped, "agent integration (pass --agents to install Claude hooks and contracts)")
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
- install_template("quality_gate_fast.rb.tt", ".claude/hooks/quality_gate_fast.rb", mode: HOOK_MODE)
320
- install_template(
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; test/test_helper.rb is missing."
661
- record(:skipped, "test/test_helper.rb (missing; Minitest coverage wiring 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
- [summary_heading(result), checks_table(result.checks), findings_section(result.findings)].compact
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.findings.each do |finding|
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? ? DEFAULT_COMMANDS.fetch(framework) : parse_command(value)
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