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
@@ -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,7 +2,9 @@
2
2
 
3
3
  require "erb"
4
4
  require "fileutils"
5
+ require "json"
5
6
  require "pathname"
7
+ require "shellwords"
6
8
  require "tempfile"
7
9
 
8
10
  module QualityGate
@@ -18,6 +20,9 @@ module QualityGate
18
20
  .claude/settings.json
19
21
  CLAUDE.md
20
22
  AGENTS.md
23
+ .codex/hooks.json
24
+ .codex/hooks/quality_gate_fast.rb
25
+ .codex/hooks/quality_gate_verify_stop.rb
21
26
  ].freeze
22
27
  MARKER_START = "# quality_gate coverage — start"
23
28
  MARKER_END = "# quality_gate coverage — end"
@@ -27,7 +32,10 @@ module QualityGate
27
32
  AGENT_TEMPLATE_PATHS = {
28
33
  ".claude/hooks/quality_gate_fast.rb" => "quality_gate_fast.rb.tt",
29
34
  ".claude/hooks/quality_gate_verify_stop.rb" => "quality_gate_verify_stop.rb.tt",
30
- ".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"
31
39
  }.freeze
32
40
  AGENT_CONTRACT_PATHS = %w[CLAUDE.md AGENTS.md].freeze
33
41
  PRIOR_CLAUDE_SETTINGS = [
@@ -51,6 +59,25 @@ module QualityGate
51
59
  }
52
60
  JSON
53
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
54
81
  STRONG_MIGRATIONS_PROVENANCE = "# Generated by Quality Gate."
55
82
  HOOK_MODE = 0o755
56
83
  DEFAULT_AGENT_ARTIFACT_MODE = 0o644
@@ -193,7 +220,7 @@ module QualityGate
193
220
  private_constant :PreparedTempfile
194
221
  private_constant :DirectoryOperations
195
222
  private_constant :AGENT_TEMPLATE_PATHS, :AGENT_CONTRACT_PATHS
196
- private_constant :PRIOR_CLAUDE_SETTINGS
223
+ private_constant :PRIOR_CLAUDE_SETTINGS, :PRIOR_CODEX_HOOKS
197
224
  private_constant :AGENT_CONTRACT_START, :AGENT_CONTRACT_END
198
225
  private_constant :HOOK_MODE, :DEFAULT_AGENT_ARTIFACT_MODE, :TEMPFILE_MODE
199
226
 
@@ -224,7 +251,7 @@ module QualityGate
224
251
  relative_path = test_helper_path
225
252
  path = safe_destination_path(relative_path)
226
253
  return unless path
227
- return skip_missing_test_helper unless File.file?(path)
254
+ return skip_missing_test_helper(relative_path) unless File.file?(path)
228
255
 
229
256
  install_coverage(path, relative_path)
230
257
  end
@@ -232,22 +259,25 @@ module QualityGate
232
259
  def create_agent_integration
233
260
  return unless invoke_behavior?
234
261
 
235
- unless options[:agents]
236
- 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
+ )
237
267
  return
238
268
  end
239
269
 
240
- install_template("quality_gate_fast.rb.tt", ".claude/hooks/quality_gate_fast.rb", mode: HOOK_MODE)
241
- install_template(
242
- "quality_gate_verify_stop.rb.tt",
243
- ".claude/hooks/quality_gate_verify_stop.rb",
244
- mode: HOOK_MODE
245
- )
246
- install_template("claude_settings.json.tt", ".claude/settings.json")
247
- install_agent_contract("CLAUDE.md")
270
+ install_claude_integration if options[:agents]
271
+ install_codex_integration if options[:codex]
248
272
  install_agent_contract("AGENTS.md")
249
273
  end
250
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
+
251
281
  def print_summary
252
282
  return print_unsupported_behavior unless invoke_behavior?
253
283
 
@@ -261,10 +291,31 @@ module QualityGate
261
291
 
262
292
  private
263
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
+
264
311
  def invoke_behavior?
265
312
  behavior == :invoke
266
313
  end
267
314
 
315
+ def agent_option?(name)
316
+ options.fetch(name, options.fetch(name.to_s, false))
317
+ end
318
+
268
319
  def print_unsupported_behavior
269
320
  say "Automatic removal is not supported. See the README installation undo instructions for manual removal."
270
321
  end
@@ -284,7 +335,7 @@ module QualityGate
284
335
  existing = existing_file.fetch(:content)
285
336
  if existing == content.b
286
337
  record_existing_template(path, relative_path, existing:, mode:, status: existing_file.fetch(:status))
287
- elsif prior_claude_settings?(relative_path, existing)
338
+ elsif prior_claude_settings?(relative_path, existing) || prior_codex_hooks?(relative_path, existing)
288
339
  return record_pretend_skip(relative_path) if options[:pretend]
289
340
 
290
341
  replace_content(path, content, relative_path, expected: existing_file)
@@ -302,6 +353,10 @@ module QualityGate
302
353
  relative_path == ".claude/settings.json" && PRIOR_CLAUDE_SETTINGS.include?(content)
303
354
  end
304
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
+
305
360
  def install_agent_contract(relative_path)
306
361
  path = safe_destination_path(relative_path)
307
362
  return unless path
@@ -577,9 +632,9 @@ module QualityGate
577
632
  markers
578
633
  end
579
634
 
580
- def skip_missing_test_helper
581
- say "Warning: Minitest coverage wiring skipped; test/test_helper.rb is missing."
582
- 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)")
583
638
  end
584
639
 
585
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
@@ -23,22 +23,24 @@ module QualityGate
23
23
 
24
24
  # Runs adapters in order and reduces their findings into one immutable result.
25
25
  class Runner
26
- Result = Data.define(:findings, :failed_tools, :checks) do
27
- def initialize(findings:, checks: [])
26
+ Result = Data.define(:findings, :failed_tools, :checks, :baseline) do
27
+ def initialize(findings:, checks: [], baseline: nil)
28
28
  copied_findings = findings.dup.freeze
29
29
  failed_tools = copied_findings.select(&:tool_failure?).map(&:tool).uniq
30
30
  copied_checks = RunnerValueCopy.freeze_copy(checks)
31
+ copied_baseline = RunnerValueCopy.freeze_copy(baseline)
31
32
 
32
33
  super(
33
34
  findings: copied_findings,
34
35
  failed_tools: failed_tools.map { _1.dup.freeze }.freeze,
35
- checks: copied_checks
36
+ checks: copied_checks,
37
+ baseline: copied_baseline
36
38
  )
37
39
  end
38
40
 
39
41
  # Override Data#with so replacements cannot bypass the derived-state invariant.
40
- def with(findings: self.findings, checks: self.checks)
41
- self.class.new(findings: findings, checks: checks)
42
+ def with(findings: self.findings, checks: self.checks, baseline: self.baseline)
43
+ self.class.new(findings: findings, checks: checks, baseline: baseline)
42
44
  end
43
45
 
44
46
  def exit_code
@@ -52,13 +54,17 @@ module QualityGate
52
54
  CHECK_SCOPES = {
53
55
  "rubocop" => "selected_files",
54
56
  "reek" => "selected_files",
57
+ "herb" => "selected_files",
55
58
  "test_suite" => "test_suite",
56
59
  "undercover" => "git_diff",
57
60
  "simplecov" => "coverage_summary",
58
61
  "brakeman" => "project",
59
- "bundler_audit" => "lockfile"
62
+ "bundler_audit" => "lockfile",
63
+ "rubycritic" => "project",
64
+ "debride" => "project",
65
+ "database_consistency" => "project"
60
66
  }.freeze
61
- SELECTION_SUPPORTED_TOOLS = %w[rubocop reek].freeze
67
+ SELECTION_SUPPORTED_TOOLS = %w[rubocop reek herb].freeze
62
68
  private_constant :CHECK_SCOPES, :SELECTION_SUPPORTED_TOOLS
63
69
 
64
70
  def initialize(adapters:, config:, diagnostic_io: nil)
@@ -72,7 +78,7 @@ module QualityGate
72
78
  checks = []
73
79
 
74
80
  adapters.each do |adapter|
75
- tool = adapter_name(adapter)
81
+ tool = adapter.name.to_s
76
82
  write_progress(tool, scope_for(tool))
77
83
  started_at = monotonic_time
78
84
  adapter_findings = adapter.call
@@ -87,24 +93,16 @@ module QualityGate
87
93
 
88
94
  attr_reader :adapters, :config, :diagnostic_io
89
95
 
90
- def adapter_name(adapter)
91
- adapter.name.to_s
92
- end
93
-
94
96
  def check_for(tool, findings, duration_ms)
95
97
  {
96
98
  tool: tool,
97
99
  status: status_for(findings),
98
100
  scope: scope_for(tool),
99
- requested_files: selection_supported?(tool) ? requested_files : [],
101
+ requested_files: SELECTION_SUPPORTED_TOOLS.include?(tool) ? requested_files : [],
100
102
  duration_ms: duration_ms
101
103
  }
102
104
  end
103
105
 
104
- def selection_supported?(tool)
105
- SELECTION_SUPPORTED_TOOLS.include?(tool)
106
- end
107
-
108
106
  def scope_for(tool)
109
107
  scope = CHECK_SCOPES.fetch(tool, "unknown")
110
108
  return "project" if scope == "selected_files" && requested_files.empty?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module QualityGate
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/quality_gate.rb CHANGED
@@ -14,15 +14,30 @@ module QualityGate
14
14
  # Your code goes here...
15
15
  end
16
16
 
17
+ require_relative "quality_gate/baseline"
18
+ require_relative "quality_gate/baseline_run"
17
19
  require_relative "quality_gate/adapter"
18
20
  require_relative "quality_gate/adapters/brakeman"
19
21
  require_relative "quality_gate/adapters/bundler_audit"
22
+ require_relative "quality_gate/adapters/database_consistency"
23
+ require_relative "quality_gate/adapters/herb"
20
24
  require_relative "quality_gate/adapters/reek"
21
25
  require_relative "quality_gate/adapters/rubocop"
26
+ require_relative "quality_gate/adapters/rubycritic"
27
+ require_relative "quality_gate/adapters/debride"
22
28
  require_relative "quality_gate/adapters/simplecov"
23
29
  require_relative "quality_gate/adapters/test_suite"
24
30
  require_relative "quality_gate/adapters/undercover"
25
31
  require_relative "quality_gate/config"
26
32
  require_relative "quality_gate/runner"
33
+ require_relative "quality_gate/doctor_report"
34
+ require_relative "quality_gate/reporters/doctor"
35
+ require_relative "quality_gate/doctor_bounded_file"
36
+ require_relative "quality_gate/doctor_launchers"
37
+ require_relative "quality_gate/doctor_git"
38
+ require_relative "quality_gate/doctor_coverage"
39
+ require_relative "quality_gate/doctor_hooks"
40
+ require_relative "quality_gate/doctor"
41
+ require_relative "quality_gate/doctor_command"
27
42
  require_relative "quality_gate/cli"
28
43
  require_relative "quality_gate/railtie" if defined?(Rails::Railtie)