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
@@ -6,12 +6,13 @@ module QualityGate
6
6
  # Parses command-line input and coordinates configured quality gates.
7
7
  # rubocop:disable Metrics/ClassLength
8
8
  class CLI
9
- SUBCOMMANDS = %w[fast verify audit version init].map!(&:freeze).freeze
10
- GATE_SUBCOMMANDS = SUBCOMMANDS.first(3).freeze
9
+ SUBCOMMANDS = %w[fast verify audit deep version init doctor].map!(&:freeze).freeze
10
+ GATE_SUBCOMMANDS = %w[fast verify audit deep].map!(&:freeze).freeze
11
11
  GATE_TOOLS = {
12
12
  "fast" => %w[rubocop].freeze,
13
13
  "verify" => %w[reek test_suite undercover].freeze,
14
- "audit" => %w[brakeman bundler_audit].freeze
14
+ "audit" => %w[brakeman bundler_audit].freeze,
15
+ "deep" => %w[rubycritic].freeze
15
16
  }.freeze
16
17
 
17
18
  class << self
@@ -61,6 +62,7 @@ module QualityGate
61
62
  end
62
63
  return print_version(stdout, stderr) if subcommand == "version" && arguments.empty?
63
64
  return run_init(arguments, stdout: stdout, stderr: stderr, dir: dir) if subcommand == "init"
65
+ return run_doctor(arguments, stdout: stdout, stderr: stderr, dir: dir) if subcommand == "doctor"
64
66
 
65
67
  nil
66
68
  end
@@ -71,6 +73,10 @@ module QualityGate
71
73
  InitCommand.run(arguments, stdout: stdout, stderr: stderr, dir: dir)
72
74
  end
73
75
 
76
+ def run_doctor(arguments, stdout:, stderr:, dir:)
77
+ DoctorCommand.new(dir:, registry: registry).run(arguments, stdout:, stderr:)
78
+ end
79
+
74
80
  def execute_command(subcommand, arguments, stdout:, stderr:, dir:, format:)
75
81
  unless GATE_SUBCOMMANDS.include?(subcommand)
76
82
  return invalid_command(subcommand, stdout: stdout, stderr: stderr, format: format)
@@ -87,6 +93,9 @@ module QualityGate
87
93
 
88
94
  settings = config.to_h.merge(overrides)
89
95
  validate_paths!(settings.fetch(:files), dir)
96
+ operation = baseline_operation(subcommand, settings, overrides, dir)
97
+ settings[:baseline_operation] = operation if operation
98
+ settings.delete(:baseline_option)
90
99
  validate_simplecov_configuration!(subcommand, settings, config.path || File.join(dir, ".quality_gate.yml"))
91
100
  dispatch(subcommand, settings, stdout: stdout, stderr: stderr)
92
101
  end
@@ -161,6 +170,7 @@ module QualityGate
161
170
 
162
171
  def option_parser(overrides)
163
172
  OptionParser.new.tap do |parser|
173
+ add_baseline_options(parser, overrides)
164
174
  parser.on("--files PATH") do |path|
165
175
  raise OptionParser::InvalidOption, "--files" if overrides.key?(:files)
166
176
 
@@ -171,6 +181,54 @@ module QualityGate
171
181
  end
172
182
  end
173
183
 
184
+ def add_baseline_options(parser, overrides)
185
+ parser.on("--baseline PATH") { |path| set_baseline_option(overrides, :compare, path) }
186
+ parser.on("--create-baseline PATH") { |path| set_baseline_option(overrides, :create, path) }
187
+ parser.on("--ratchet-baseline PATH") { |path| set_baseline_option(overrides, :ratchet, path) }
188
+ end
189
+
190
+ def set_baseline_option(overrides, mode, path)
191
+ raise OptionParser::InvalidOption, "baseline options are mutually exclusive" if overrides.key?(:baseline_option)
192
+ raise OptionParser::InvalidArgument, "baseline path must be non-empty" if path.empty?
193
+
194
+ overrides[:baseline_option] = { mode:, path: }
195
+ end
196
+
197
+ def baseline_operation(subcommand, settings, overrides, dir)
198
+ requested = overrides[:baseline_option]
199
+ return unless requested || settings.fetch(:baseline).key?(subcommand.to_sym)
200
+
201
+ validate_baseline_gate!(subcommand)
202
+ operation = requested || configured_baseline(settings, subcommand)
203
+ validate_baseline_scope!(operation, settings, overrides)
204
+ resolved_baseline(operation, dir)
205
+ end
206
+
207
+ def validate_baseline_gate!(subcommand)
208
+ return if %w[fast verify].include?(subcommand)
209
+
210
+ raise ArgumentError, "baselines are supported only for fast and verify"
211
+ end
212
+
213
+ def configured_baseline(settings, subcommand)
214
+ { mode: :compare, path: settings.fetch(:baseline).fetch(subcommand.to_sym) }
215
+ end
216
+
217
+ def validate_baseline_scope!(operation, settings, overrides)
218
+ return unless %i[create ratchet].include?(operation.fetch(:mode))
219
+ return unless overrides.key?(:files) || settings.fetch(:files).any?
220
+
221
+ raise ArgumentError, "baseline creation and ratcheting require the full configured file scope"
222
+ end
223
+
224
+ def resolved_baseline(operation, dir)
225
+ {
226
+ mode: operation.fetch(:mode),
227
+ path: File.expand_path(operation.fetch(:path), dir),
228
+ root: File.expand_path(dir)
229
+ }
230
+ end
231
+
174
232
  def append_remaining_files(overrides, arguments)
175
233
  if overrides.key?(:files)
176
234
  overrides[:files].concat(arguments)
@@ -204,6 +262,7 @@ module QualityGate
204
262
  end
205
263
 
206
264
  def dispatch(subcommand, settings, stdout:, stderr:)
265
+ operation = settings.delete(:baseline_operation)
207
266
  config = Config.new(settings)
208
267
  diagnostic_io = terminal_diagnostic_io(stderr)
209
268
  result = Runner.new(
@@ -212,6 +271,8 @@ module QualityGate
212
271
  diagnostic_io: diagnostic_io
213
272
  ).call
214
273
 
274
+ result = apply_baseline_operation(result, operation, subcommand, settings) if operation
275
+
215
276
  begin
216
277
  reporter_for(settings.fetch(:format), stdout: stdout).call(result)
217
278
  rescue StandardError => e
@@ -222,6 +283,11 @@ module QualityGate
222
283
  result.exit_code
223
284
  end
224
285
 
286
+ def apply_baseline_operation(result, operation, subcommand, settings)
287
+ tools = settings.fetch(:adapters).fetch(subcommand.to_sym)
288
+ BaselineRun.new(operation:, gate: subcommand, tools:).call(result)
289
+ end
290
+
225
291
  def adapters_for(subcommand, settings:, config:, diagnostic_io:)
226
292
  settings.fetch(:adapters).fetch(subcommand.to_sym).map do |adapter_name|
227
293
  build_adapter(
@@ -282,7 +348,7 @@ module QualityGate
282
348
  return gate_help_text(gate) if gate
283
349
 
284
350
  <<~TEXT
285
- Usage: quality_gate <fast|verify|audit|version|init> [options]
351
+ Usage: quality_gate <fast|verify|audit|deep|version|init|doctor> [options]
286
352
 
287
353
  Run one Quality Gate check for the current project.
288
354
 
@@ -290,24 +356,32 @@ module QualityGate
290
356
  fast RuboCop (quick feedback for selected files)
291
357
  verify Reek, test_suite, and Undercover (tests and coverage)
292
358
  audit Brakeman and bundler_audit (security checks)
359
+ deep RubyCritic (complexity and duplication checks; optional)
293
360
  version Print the Quality Gate version
294
361
  init Install a plain Ruby project configuration
362
+ doctor Inspect bounded preflight readiness without running gates
295
363
 
296
364
  Options:
297
365
  --files PATH [PATH ...] Replace configured paths for this run; files and directories are allowed
298
366
  --format FORMAT Choose text, json, or markdown output (also accepts --format=FORMAT)
367
+ --baseline PATH Accept recorded warnings on fast/verify
368
+ --create-baseline PATH Create a warning snapshot on fast/verify
369
+ --ratchet-baseline PATH Shrink a clean fast/verify snapshot
299
370
  -h, --help Show this help
300
371
 
301
372
  Scope:
302
373
  Without --files, the configured files are used. With --files, the command-line paths replace them.
303
374
  RuboCop and Reek scan selected paths. The test suite runs in full; Undercover checks the Git diff.
304
375
  Brakeman scans the application and bundler-audit scans the lockfile.
376
+ RubyCritic and Debride scan the whole project; --files does not narrow their scans.
305
377
 
306
378
  Examples:
307
379
  quality_gate fast
308
380
  quality_gate fast --files app/models/user.rb test/models
309
381
  quality_gate verify --format json
310
382
  quality_gate verify --format markdown
383
+ quality_gate doctor --format json
384
+ quality_gate deep --format json
311
385
 
312
386
  Exit status:
313
387
  0 Checks completed cleanly
@@ -318,6 +392,9 @@ module QualityGate
318
392
 
319
393
  def gate_help_text(gate)
320
394
  tools = GATE_TOOLS.fetch(gate).join(", ")
395
+ return deep_gate_help_text(tools) if gate == "deep"
396
+
397
+ baseline_options = baseline_help_options(gate)
321
398
 
322
399
  <<~TEXT
323
400
  Usage: quality_gate #{gate} [options]
@@ -327,6 +404,7 @@ module QualityGate
327
404
  Options:
328
405
  --files PATH [PATH ...] Replace configured paths for this run; files and directories are allowed
329
406
  --format FORMAT Choose text, json, or markdown output (also accepts --format=FORMAT)
407
+ #{baseline_options.chomp}
330
408
  -h, --help Show this help
331
409
 
332
410
  Scope:
@@ -346,6 +424,44 @@ module QualityGate
346
424
  TEXT
347
425
  end
348
426
 
427
+ def baseline_help_options(gate)
428
+ return "" unless %w[fast verify].include?(gate)
429
+
430
+ [
431
+ "--baseline PATH Compare with a warning snapshot",
432
+ " --create-baseline PATH Create a warning snapshot",
433
+ " --ratchet-baseline PATH Shrink a clean snapshot"
434
+ ].join("\n")
435
+ end
436
+
437
+ def deep_gate_help_text(tools)
438
+ <<~TEXT
439
+ Usage: quality_gate deep [options]
440
+
441
+ Run the deep gate using these default tools: #{tools}.
442
+ RubyCritic is the default analyzer, optional and installed separately.
443
+ Enable optional Debride with adapters.deep: [debride]; install it separately.
444
+
445
+ Options:
446
+ --files PATH [PATH ...] Validate paths for this run; deep analyzers scan the whole project
447
+ --format FORMAT Choose text, json, or markdown output (also accepts --format=FORMAT)
448
+ -h, --help Show this help
449
+
450
+ Scope:
451
+ Deep analyzers scan the whole project. --files paths are validated but do not narrow their scans.
452
+
453
+ Examples:
454
+ quality_gate deep
455
+ quality_gate deep --format json
456
+ # Configure adapters.deep: [debride] to run Debride
457
+
458
+ Exit status:
459
+ 0 Checks completed cleanly
460
+ 1 Checks completed with findings
461
+ 2 Quality Gate could not complete (input, config, or tool failure)
462
+ TEXT
463
+ end
464
+
349
465
  def init_help_text
350
466
  <<~TEXT
351
467
  Usage: quality_gate init [options]
@@ -358,7 +474,9 @@ module QualityGate
358
474
  --test-helper PATH Use a custom test helper
359
475
  --test-command COMMAND Override the test command
360
476
  --skip-coverage Omit coverage and Undercover setup
361
- --agents Install optional agent hooks and contracts
477
+ --agents Install optional Claude Code hooks and contracts
478
+ --codex Install optional Codex patch feedback and Stop verification
479
+ --ci Generate an optional GitHub Actions workflow
362
480
  --pretend Preview changes without writing them
363
481
  -h, --help Show this help
364
482
 
@@ -367,6 +485,7 @@ module QualityGate
367
485
  Examples:
368
486
  quality_gate init
369
487
  quality_gate init --test-framework rspec
488
+ quality_gate init --codex
370
489
  quality_gate init --pretend
371
490
  TEXT
372
491
  end
@@ -430,12 +549,16 @@ module QualityGate
430
549
  def registry
431
550
  {
432
551
  "reek" => QualityGate::Adapters::Reek,
552
+ "herb" => QualityGate::Adapters::Herb,
433
553
  "rubocop" => QualityGate::Adapters::RuboCop,
434
554
  "simplecov" => QualityGate::Adapters::SimpleCov,
435
555
  "test_suite" => QualityGate::Adapters::TestSuite,
436
556
  "undercover" => QualityGate::Adapters::Undercover,
437
557
  "brakeman" => QualityGate::Adapters::Brakeman,
438
- "bundler_audit" => QualityGate::Adapters::BundlerAudit
558
+ "bundler_audit" => QualityGate::Adapters::BundlerAudit,
559
+ "database_consistency" => QualityGate::Adapters::DatabaseConsistency,
560
+ "rubycritic" => QualityGate::Adapters::RubyCritic,
561
+ "debride" => QualityGate::Adapters::Debride
439
562
  }.freeze
440
563
  end
441
564
 
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+ require "stringio"
6
+ require_relative "codex_patch_files"
7
+
8
+ module QualityGate
9
+ # Runs file-scoped fast checks after Codex applies a native patch.
10
+ class CodexFastHook
11
+ FINDING_STRING_FIELDS = %w[tool file rule severity message].freeze
12
+ private_constant :FINDING_STRING_FIELDS
13
+
14
+ class Report
15
+ def self.parse(output)
16
+ report = JSON.parse(output)
17
+ raise TypeError unless valid?(report)
18
+
19
+ report
20
+ end
21
+
22
+ def self.valid?(report)
23
+ report.is_a?(Hash) && valid_findings?(report["findings"]) &&
24
+ valid_summary?(report["summary"], report.fetch("findings").length)
25
+ end
26
+
27
+ def self.valid_findings?(findings)
28
+ findings.is_a?(Array) && findings.all? { valid_finding?(_1) }
29
+ end
30
+
31
+ def self.valid_finding?(finding)
32
+ return false unless finding.is_a?(Hash) && FINDING_STRING_FIELDS.all? { finding[_1].is_a?(String) }
33
+
34
+ line = finding["line"]
35
+ line.is_a?(Integer) && line >= 0 && Finding::SEVERITIES.any? { _1.to_s == finding["severity"] } &&
36
+ finding["rule"] != Finding::TOOL_FAILURE_RULE
37
+ end
38
+
39
+ def self.valid_summary?(summary, count)
40
+ return false unless summary.is_a?(Hash)
41
+
42
+ findings, failures = summary.values_at("findings", "tool_failures")
43
+ findings.is_a?(Integer) && findings == count && failures.is_a?(Integer) &&
44
+ failures.zero? && summary["failed_tools"] == []
45
+ end
46
+ private_class_method :valid_findings?, :valid_finding?, :valid_summary?
47
+ end
48
+ private_constant :Report
49
+
50
+ def initialize(dir:)
51
+ @dir = File.expand_path(dir)
52
+ end
53
+
54
+ def call(input)
55
+ return unavailable unless valid_input?(input)
56
+
57
+ evaluate(input)
58
+ rescue StandardError
59
+ unavailable
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :dir
65
+
66
+ def valid_input?(input)
67
+ input.is_a?(Hash) && input["hook_event_name"] == "PostToolUse" && input["tool_name"] == "apply_patch" &&
68
+ input.dig("tool_input", "command").is_a?(String) && absolute_existing_path?(input["cwd"])
69
+ rescue TypeError, NoMethodError
70
+ false
71
+ end
72
+
73
+ def evaluate(input)
74
+ root = File.realpath(dir)
75
+ cwd = File.realpath(input.fetch("cwd"))
76
+ return unavailable unless inside?(cwd, root) && File.directory?(cwd)
77
+
78
+ files = selected_files(input.fetch("tool_input").fetch("command"), cwd, root)
79
+ files.empty? ? {} : run_fast(files)
80
+ end
81
+
82
+ def absolute_existing_path?(path)
83
+ path.is_a?(String) && Pathname.new(path).absolute? && File.directory?(path)
84
+ end
85
+
86
+ def inside?(path, root)
87
+ path == root || path.start_with?("#{root}#{File::SEPARATOR}")
88
+ end
89
+
90
+ def selected_files(patch, cwd, root)
91
+ paths = CodexPatchFiles.parse(patch)
92
+ ruby_files = resolved_files(paths, ".rb", cwd, root)
93
+ erb_files = resolved_files(paths, ".erb", cwd, root)
94
+ return ruby_files if erb_files.empty? || !herb_enabled?(root)
95
+
96
+ ruby_files + erb_files
97
+ end
98
+
99
+ def resolved_files(paths, extension, cwd, root)
100
+ paths.filter_map { resolved_file(_1, cwd, root) if File.extname(_1) == extension }.uniq
101
+ end
102
+
103
+ def herb_enabled?(root)
104
+ Config.load(dir: root).fetch(:adapters).fetch(:fast).include?("herb")
105
+ end
106
+
107
+ def resolved_file(path, cwd, root)
108
+ expanded = File.expand_path(path, cwd)
109
+ return unless File.file?(expanded)
110
+
111
+ canonical_file(expanded, root)
112
+ rescue SystemCallError
113
+ nil
114
+ end
115
+
116
+ def canonical_file(path, root)
117
+ canonical = File.realpath(path)
118
+ canonical if inside?(canonical, root) && File.file?(canonical)
119
+ end
120
+
121
+ def run_fast(files)
122
+ stdout = StringIO.new
123
+ status = CLI.run(["fast", "--format", "json", "--files", *files], stdout:, stderr: StringIO.new, dir:)
124
+ respond_to_report(status, Report.parse(stdout.string))
125
+ end
126
+
127
+ def respond_to_report(status, report)
128
+ findings = report.fetch("findings")
129
+ return {} if status.zero? && findings.empty?
130
+ return unavailable unless status == 1 && findings.any?
131
+
132
+ additional_context("Fix these Quality Gate findings after this edit:\n#{format_findings(findings)}")
133
+ end
134
+
135
+ def format_findings(findings)
136
+ findings.map { format_finding(_1) }.join("\n")
137
+ end
138
+
139
+ def format_finding(finding)
140
+ "#{finding.fetch("file")}:#{finding.fetch("line")} " \
141
+ "[#{finding.fetch("tool")}/#{finding.fetch("rule")}] #{finding.fetch("message")}"
142
+ end
143
+
144
+ def unavailable
145
+ message = "Quality Gate fast feedback unavailable; run `bundle exec quality_gate fast` manually."
146
+ { "systemMessage" => message }.merge(
147
+ "hookSpecificOutput" => hook_output(unavailable_context)
148
+ )
149
+ end
150
+
151
+ def unavailable_context
152
+ "Quality Gate fast feedback is unavailable. Run `bundle exec quality_gate fast` manually."
153
+ end
154
+
155
+ def additional_context(context)
156
+ { "hookSpecificOutput" => hook_output(context) }
157
+ end
158
+
159
+ def hook_output(context)
160
+ { "hookEventName" => "PostToolUse", "additionalContext" => context }
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QualityGate
4
+ # Extracts native apply_patch file operations without interpreting hunk content as paths.
5
+ class CodexPatchFiles
6
+ BEGIN_MARKER = "*** Begin Patch"
7
+ END_MARKER = "*** End Patch"
8
+ OPERATIONS = %w[Add Update Delete].freeze
9
+ private_constant :BEGIN_MARKER, :END_MARKER, :OPERATIONS
10
+
11
+ def self.parse(patch)
12
+ new(patch).call
13
+ end
14
+
15
+ def initialize(patch)
16
+ @lines = patch.lines(chomp: true).map { _1.delete_suffix("\r") }
17
+ @paths = []
18
+ @section = nil
19
+ end
20
+
21
+ def call
22
+ validate_patch!
23
+ lines[1...-1].each { process_line(_1) }
24
+ paths
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :lines, :paths, :section
30
+
31
+ def validate_patch!
32
+ raise ArgumentError unless lines.length >= 3 && lines.first == BEGIN_MARKER && lines.last == END_MARKER
33
+ end
34
+
35
+ def process_line(line)
36
+ process_operation(line) || process_move(line) || process_end_of_file(line) || process_content(line)
37
+ end
38
+
39
+ def process_operation(line)
40
+ match = line.match(/\A\*\*\* (#{OPERATIONS.join("|")}) File: (.+)\z/)
41
+ return false unless match
42
+
43
+ record_operation(match)
44
+ true
45
+ end
46
+
47
+ def record_operation(match)
48
+ @section = { operation: match[1], moved: false, in_hunk: false }
49
+ paths << match[2] unless match[1] == "Delete"
50
+ end
51
+
52
+ def process_move(line)
53
+ return false unless line.start_with?("*** Move to: ")
54
+
55
+ apply_move(line.delete_prefix("*** Move to: "))
56
+ true
57
+ end
58
+
59
+ def apply_move(destination)
60
+ valid_move!(destination)
61
+ paths[-1] = destination
62
+ section[:moved] = true
63
+ end
64
+
65
+ def valid_move!(destination)
66
+ valid = section && section.fetch(:operation) == "Update" && !section.fetch(:moved) &&
67
+ !section.fetch(:in_hunk) && !destination.empty?
68
+ raise ArgumentError unless valid
69
+ end
70
+
71
+ def process_end_of_file(line)
72
+ return false unless line == "*** End of File"
73
+
74
+ valid = section && section.fetch(:operation) == "Update" && section.fetch(:in_hunk)
75
+ raise ArgumentError unless valid
76
+
77
+ section[:in_hunk] = true
78
+ true
79
+ end
80
+
81
+ def process_content(line)
82
+ raise ArgumentError unless valid_content_line?(line)
83
+
84
+ section[:in_hunk] = true
85
+ end
86
+
87
+ def valid_content_line?(line)
88
+ section && valid_content?(line)
89
+ end
90
+
91
+ def valid_content?(line)
92
+ line.start_with?("+", "-", " ", "@@", "\")
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "stringio"
5
+
6
+ module QualityGate
7
+ # Runs the full verification gate at a native Codex Stop boundary.
8
+ class CodexStopHook
9
+ VERIFY_ARGUMENTS = %w[verify --format json].freeze
10
+ STRING_FIELDS = %w[tool file rule severity message].freeze
11
+ private_constant :VERIFY_ARGUMENTS, :STRING_FIELDS
12
+
13
+ def initialize(dir:)
14
+ @dir = File.expand_path(dir)
15
+ end
16
+
17
+ def call(input)
18
+ return unavailable unless valid_input?(input)
19
+
20
+ run_verify(input.fetch("stop_hook_active"))
21
+ rescue StandardError
22
+ unavailable
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :dir
28
+
29
+ def valid_input?(input)
30
+ input.is_a?(Hash) && input["hook_event_name"] == "Stop" &&
31
+ [true, false].include?(input["stop_hook_active"])
32
+ end
33
+
34
+ def run_verify(active)
35
+ stdout = StringIO.new
36
+ status = CLI.run(VERIFY_ARGUMENTS, stdout:, stderr: StringIO.new, dir:)
37
+ respond_to_report(status, parse_report(stdout.string), active)
38
+ end
39
+
40
+ def parse_report(output)
41
+ report = JSON.parse(output)
42
+ raise TypeError unless valid_report?(report)
43
+
44
+ report
45
+ end
46
+
47
+ def valid_report?(report)
48
+ return false unless report.is_a?(Hash) && valid_findings?(report["findings"])
49
+
50
+ valid_summary?(report["summary"], report["findings"].length)
51
+ end
52
+
53
+ def valid_findings?(findings)
54
+ findings.is_a?(Array) && findings.all? { valid_finding?(_1) }
55
+ end
56
+
57
+ def valid_finding?(finding)
58
+ finding.is_a?(Hash) && STRING_FIELDS.all? { finding[_1].is_a?(String) } && finding["line"].is_a?(Integer) &&
59
+ finding["line"] >= 0 && Finding::SEVERITIES.any? { _1.to_s == finding["severity"] } &&
60
+ finding["rule"] != Finding::TOOL_FAILURE_RULE
61
+ end
62
+
63
+ def valid_summary?(summary, count)
64
+ return false unless summary.is_a?(Hash)
65
+
66
+ findings, failures = summary.values_at("findings", "tool_failures")
67
+ findings.is_a?(Integer) && findings == count && failures.is_a?(Integer) &&
68
+ failures.zero? && summary["failed_tools"] == []
69
+ end
70
+
71
+ def respond_to_report(status, report, active)
72
+ findings = report.fetch("findings")
73
+ return {} if status.zero? && findings.empty?
74
+ return unavailable unless status == 1 && findings.any?
75
+ return capped(findings) if active
76
+
77
+ { "decision" => "block", "reason" => "Fix these findings before finishing:\n#{format_findings(findings)}" }
78
+ end
79
+
80
+ def format_findings(findings)
81
+ findings.map { format_finding(_1) }.join("\n")
82
+ end
83
+
84
+ def format_finding(finding)
85
+ file = finding.fetch("file")
86
+ line = finding.fetch("line")
87
+ tool, rule, message = finding.values_at("tool", "rule", "message")
88
+ "#{file}:#{line} [#{tool}/#{rule}] #{message}"
89
+ end
90
+
91
+ def capped(findings)
92
+ message = "Quality Gate continuation limit reached; remaining findings:\n#{format_findings(findings)}"
93
+ { "systemMessage" => message }
94
+ end
95
+
96
+ def unavailable
97
+ message = "Quality Gate verification unavailable; run " \
98
+ "`bundle exec quality_gate verify --format json` manually."
99
+ { "systemMessage" => message }
100
+ end
101
+ end
102
+ end