quality_gate 0.1.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 (62) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +571 -0
  5. data/config/37signals.yml +26 -0
  6. data/config/cops.yml +37 -0
  7. data/config/reek.yml +6 -0
  8. data/config/rubocop.yml +60 -0
  9. data/config/ruby.yml +8 -0
  10. data/docs/codex.md +31 -0
  11. data/docs/dogfood-log.md +47 -0
  12. data/docs/incidents.md +18 -0
  13. data/docs/releasing.md +46 -0
  14. data/exe/quality_gate +6 -0
  15. data/lib/generators/quality_gate/install/install_generator.rb +66 -0
  16. data/lib/generators/quality_gate/install/templates/agents_section.md.tt +15 -0
  17. data/lib/generators/quality_gate/install/templates/bullet.rb.tt +10 -0
  18. data/lib/generators/quality_gate/install/templates/claude_settings.json.tt +29 -0
  19. data/lib/generators/quality_gate/install/templates/hook_log_filesystem.rb.tt +76 -0
  20. data/lib/generators/quality_gate/install/templates/quality_gate.yml.tt +42 -0
  21. data/lib/generators/quality_gate/install/templates/quality_gate_fast.rb.tt +248 -0
  22. data/lib/generators/quality_gate/install/templates/quality_gate_verify_stop.rb.tt +466 -0
  23. data/lib/generators/quality_gate/install/templates/rubocop.yml.tt +1 -0
  24. data/lib/generators/quality_gate/install/templates/ruby_agents_section.md.tt +15 -0
  25. data/lib/generators/quality_gate/install/templates/ruby_quality_gate.yml.tt +40 -0
  26. data/lib/generators/quality_gate/install/templates/ruby_rubocop.yml.tt +1 -0
  27. data/lib/generators/quality_gate/install/templates/ruby_simplecov.rb.tt +11 -0
  28. data/lib/generators/quality_gate/install/templates/simplecov.rb.tt +10 -0
  29. data/lib/generators/quality_gate/install/templates/strong_migrations.rb.tt +10 -0
  30. data/lib/quality_gate/adapter.rb +328 -0
  31. data/lib/quality_gate/adapters/brakeman.rb +125 -0
  32. data/lib/quality_gate/adapters/bundler_audit.rb +235 -0
  33. data/lib/quality_gate/adapters/reek.rb +108 -0
  34. data/lib/quality_gate/adapters/rubocop.rb +142 -0
  35. data/lib/quality_gate/adapters/simplecov.rb +103 -0
  36. data/lib/quality_gate/adapters/test_suite.rb +81 -0
  37. data/lib/quality_gate/adapters/undercover.rb +357 -0
  38. data/lib/quality_gate/cli.rb +451 -0
  39. data/lib/quality_gate/config.rb +290 -0
  40. data/lib/quality_gate/exit_code.rb +14 -0
  41. data/lib/quality_gate/finding.rb +50 -0
  42. data/lib/quality_gate/hook_log.rb +131 -0
  43. data/lib/quality_gate/init_command.rb +90 -0
  44. data/lib/quality_gate/installation.rb +1577 -0
  45. data/lib/quality_gate/installer.rb +104 -0
  46. data/lib/quality_gate/railtie.rb +16 -0
  47. data/lib/quality_gate/reporters/field_sanitizer.rb +42 -0
  48. data/lib/quality_gate/reporters/json.rb +59 -0
  49. data/lib/quality_gate/reporters/text.rb +45 -0
  50. data/lib/quality_gate/rubocop.rb +34 -0
  51. data/lib/quality_gate/ruby_profile.rb +170 -0
  52. data/lib/quality_gate/runner.rb +150 -0
  53. data/lib/quality_gate/version.rb +5 -0
  54. data/lib/quality_gate.rb +27 -0
  55. data/lib/rubocop/cop/quality_gate/association_default_block_value.rb +46 -0
  56. data/lib/rubocop/cop/quality_gate/broadcast_in_controller.rb +64 -0
  57. data/lib/rubocop/cop/quality_gate/controller_instance_variables.rb +47 -0
  58. data/lib/rubocop/cop/quality_gate/prefer_after_save_commit.rb +84 -0
  59. data/lib/rubocop/cop/quality_gate/private_only_concern.rb +77 -0
  60. data/llm.txt +13 -0
  61. data/sig/quality_gate.rbs +226 -0
  62. metadata +260 -0
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "open3"
6
+ require "time"
7
+
8
+ begin
9
+ require "fiddle/import"
10
+ rescue LoadError
11
+ nil
12
+ end
13
+
14
+ <%= hook_log_filesystem_source -%>
15
+
16
+ HOOK_LOG_MAX_SCAN_BYTES = 1024 * 1024
17
+ HOOK_LOG_MAX_LINE_BYTES = 64 * 1024
18
+ HOOK_LOG_OUTCOMES = %w[
19
+ no_file skipped deleted clean findings unavailable
20
+ verify_skipped verify_debounced verify_clean verify_blocked verify_unavailable verify_cap
21
+ ].freeze
22
+ UNAVAILABLE_NOTICE = "quality_gate fast could not run; " \
23
+ "run `bundle exec quality_gate fast` and check log/quality_gate_hooks.jsonl\n"
24
+
25
+ def quality_gate_outcome(stdout, status)
26
+ report = JSON.parse(stdout)
27
+ return unless valid_report?(report)
28
+
29
+ outcome_for(status.exitstatus, report.fetch("findings"))
30
+ rescue JSON::ParserError, EncodingError
31
+ nil
32
+ end
33
+
34
+ def valid_report?(report)
35
+ return false unless report.is_a?(Hash)
36
+
37
+ findings = report["findings"]
38
+ summary = report["summary"]
39
+ findings.is_a?(Array) &&
40
+ findings.all? { valid_finding?(_1) } &&
41
+ summary.is_a?(Hash) &&
42
+ valid_summary?(summary, findings.length)
43
+ end
44
+
45
+ def valid_finding?(finding)
46
+ return false unless finding.is_a?(Hash)
47
+
48
+ string_fields = %w[tool file rule severity message]
49
+ string_fields.all? { finding[_1].is_a?(String) } &&
50
+ finding["line"].is_a?(Integer) &&
51
+ finding["line"] >= 0 &&
52
+ %w[error warning info].include?(finding["severity"]) &&
53
+ finding["rule"] != "tool_failure"
54
+ end
55
+
56
+ def valid_summary?(summary, finding_count)
57
+ summary_findings = summary["findings"]
58
+ tool_failures = summary["tool_failures"]
59
+ failed_tools = summary["failed_tools"]
60
+
61
+ summary_findings.is_a?(Integer) &&
62
+ summary_findings == finding_count &&
63
+ tool_failures.is_a?(Integer) &&
64
+ tool_failures.zero? &&
65
+ failed_tools == []
66
+ end
67
+
68
+ def outcome_for(exit_status, findings)
69
+ return "clean" if exit_status&.zero? && findings.empty?
70
+
71
+ "findings" if exit_status == 1 && findings.any?
72
+ end
73
+
74
+ def initial_outcome(file)
75
+ return "no_file" unless file
76
+ return "skipped" unless file.end_with?(".rb")
77
+ return "deleted" unless File.file?(file)
78
+
79
+ "unavailable"
80
+ end
81
+
82
+ def close_log_descriptor(io)
83
+ io&.close
84
+ rescue StandardError
85
+ nil
86
+ end
87
+
88
+ def last_hook_log_outcome(project_root)
89
+ root_flags = File::RDONLY | File::NOFOLLOW | HookLogFilesystem::O_NONBLOCK |
90
+ HookLogFilesystem::O_DIRECTORY
91
+ root = File.open(project_root, root_flags)
92
+ root.close_on_exec = true if root.respond_to?(:close_on_exec=)
93
+ return unless root.stat.directory?
94
+
95
+ directory = HookLogFilesystem.open_existing_log_directory(root)
96
+ return unless directory.stat.directory?
97
+
98
+ log = HookLogFilesystem.open_log_file_for_read(directory)
99
+ log_stat = log.stat
100
+ return unless log_stat.file? && log_stat.nlink == 1
101
+
102
+ log.binmode
103
+ last_valid_hook_log_outcome(log)
104
+ rescue LoadError, NotImplementedError, StandardError
105
+ nil
106
+ ensure
107
+ close_log_descriptor(log)
108
+ close_log_descriptor(directory)
109
+ close_log_descriptor(root)
110
+ end
111
+
112
+ def last_valid_hook_log_outcome(log)
113
+ hook_log_tail(log).split("\n".b).reverse_each do |line|
114
+ outcome = parse_hook_log_outcome(line)
115
+ return outcome if outcome
116
+ end
117
+ nil
118
+ end
119
+
120
+ def hook_log_tail(log)
121
+ size = log.stat.size
122
+ length = [size, HOOK_LOG_MAX_SCAN_BYTES].min
123
+ offset = size - length
124
+ starts_at_boundary = hook_log_line_boundary?(log, offset)
125
+ log.seek(offset, IO::SEEK_SET)
126
+ bytes = log.read(length) || "".b
127
+ return bytes if starts_at_boundary
128
+
129
+ newline = bytes.index("\n".b)
130
+ newline ? bytes.byteslice(newline + 1, bytes.bytesize - newline - 1) : "".b
131
+ end
132
+
133
+ def hook_log_line_boundary?(log, offset)
134
+ return true if offset.zero?
135
+
136
+ log.seek(offset - 1, IO::SEEK_SET)
137
+ log.read(1) == "\n".b
138
+ end
139
+
140
+ def parse_hook_log_outcome(line)
141
+ return if line.bytesize > HOOK_LOG_MAX_LINE_BYTES
142
+
143
+ line = line.dup.force_encoding(Encoding::UTF_8)
144
+ return unless line.valid_encoding?
145
+
146
+ record = JSON.parse(line)
147
+ record["outcome"] if valid_hook_log_record?(record)
148
+ rescue JSON::ParserError
149
+ nil
150
+ end
151
+
152
+ def valid_hook_log_record?(record)
153
+ record.is_a?(Hash) &&
154
+ record["ts"].is_a?(String) &&
155
+ record.key?("file") &&
156
+ (record["file"].nil? || record["file"].is_a?(String)) &&
157
+ HOOK_LOG_OUTCOMES.include?(record["outcome"]) &&
158
+ valid_hook_log_duration?(record["duration_ms"])
159
+ end
160
+
161
+ def valid_hook_log_duration?(duration)
162
+ duration.is_a?(Numeric) && duration.finite? && duration >= 0
163
+ end
164
+
165
+ def append_hook_log(project_root, entry)
166
+ root_flags = File::RDONLY | File::NOFOLLOW | HookLogFilesystem::O_NONBLOCK |
167
+ HookLogFilesystem::O_DIRECTORY
168
+ root = File.open(project_root, root_flags)
169
+ root.close_on_exec = true if root.respond_to?(:close_on_exec=)
170
+ return unless root.stat.directory?
171
+
172
+ directory = HookLogFilesystem.open_log_directory(root)
173
+ return unless directory.stat.directory?
174
+
175
+ log, created = HookLogFilesystem.open_log_file(directory)
176
+ log_stat = log.stat
177
+ return unless log_stat.file? && log_stat.nlink == 1
178
+
179
+ if created
180
+ mask = File.umask
181
+ begin
182
+ log.chmod(0o644 & ~mask)
183
+ ensure
184
+ File.umask(mask)
185
+ end
186
+ end
187
+ line = "#{entry}\n"
188
+ written = log.syswrite(line)
189
+ fail IOError, "short hook log write" unless written == line.bytesize
190
+ rescue LoadError, NotImplementedError, StandardError
191
+ nil
192
+ ensure
193
+ close_log_descriptor(log)
194
+ close_log_descriptor(directory)
195
+ close_log_descriptor(root)
196
+ end
197
+
198
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
199
+ input = begin
200
+ parsed = JSON.parse($stdin.read)
201
+ parsed.is_a?(Hash) ? parsed : {}
202
+ rescue JSON::ParserError
203
+ {}
204
+ end
205
+
206
+ project_root = input["cwd"]
207
+ project_root = Dir.pwd unless project_root.is_a?(String) && !project_root.empty?
208
+ tool_input = input["tool_input"]
209
+ file = tool_input["file_path"] if tool_input.is_a?(Hash)
210
+ file = nil unless file.is_a?(String) && !file.empty?
211
+ outcome = initial_outcome(file)
212
+ attempt_was_made = outcome == "unavailable"
213
+ exit_code = 0
214
+ feedback = nil
215
+
216
+ if outcome == "unavailable"
217
+ begin
218
+ stdout, _stderr, status = Open3.capture3(
219
+ "bundle", "exec", "quality_gate", "fast", "--files", file, "--format", "json",
220
+ chdir: project_root
221
+ )
222
+ outcome = quality_gate_outcome(stdout, status) || "unavailable"
223
+ if outcome == "findings"
224
+ exit_code = 2
225
+ feedback = stdout
226
+ end
227
+ rescue StandardError
228
+ outcome = "unavailable"
229
+ end
230
+ end
231
+
232
+ if attempt_was_made && outcome == "unavailable" && last_hook_log_outcome(project_root) != "unavailable"
233
+ exit_code = 2
234
+ feedback = UNAVAILABLE_NOTICE
235
+ end
236
+
237
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
238
+ entry = JSON.generate(
239
+ "ts" => Time.now.utc.iso8601(6),
240
+ "file" => file,
241
+ "outcome" => outcome,
242
+ "duration_ms" => duration_ms
243
+ )
244
+
245
+ append_hook_log(project_root, entry)
246
+
247
+ $stderr.write(feedback) if feedback
248
+ exit exit_code
@@ -0,0 +1,466 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # The standalone boundary code stays explicit so every fail-open branch is visible.
5
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
6
+ # rubocop:disable Metrics/PerceivedComplexity, Style/SignalException
7
+
8
+ require "json"
9
+ require "open3"
10
+ require "time"
11
+
12
+ begin
13
+ require "fiddle/import"
14
+ rescue LoadError
15
+ nil
16
+ end
17
+
18
+ <%= hook_log_filesystem_source -%>
19
+
20
+ HOOK_LOG_MAX_SCAN_BYTES = 1024 * 1024
21
+ HOOK_LOG_MAX_LINE_BYTES = 64 * 1024
22
+ HOOK_LOG_OUTCOMES = %w[
23
+ no_file skipped deleted clean findings unavailable
24
+ verify_skipped verify_debounced verify_clean verify_blocked verify_unavailable verify_cap
25
+ ].freeze
26
+ VERIFY_OUTCOMES = %w[
27
+ verify_skipped verify_debounced verify_clean verify_blocked verify_unavailable verify_cap
28
+ ].freeze
29
+ DEBOUNCE_DECISIVE_OUTCOMES = %w[
30
+ verify_clean verify_blocked verify_unavailable verify_cap
31
+ ].freeze
32
+ FAST_OUTCOMES = %w[no_file skipped deleted clean findings unavailable].freeze
33
+ BLOCK_LIMIT = 3
34
+ STATUS_CODES = " MADRCUT?!".bytes.freeze
35
+ C_ESCAPES = {
36
+ "a".ord => "\a".b,
37
+ "b".ord => "\b".b,
38
+ "t".ord => "\t".b,
39
+ "n".ord => "\n".b,
40
+ "v".ord => "\v".b,
41
+ "f".ord => "\f".b,
42
+ "r".ord => "\r".b,
43
+ '"'.ord => '"'.b,
44
+ "\\".ord => "\\".b
45
+ }.freeze
46
+
47
+ def quality_gate_outcome(stdout, status)
48
+ report = JSON.parse(stdout)
49
+ return unless valid_report?(report)
50
+
51
+ outcome_for(status.exitstatus, report.fetch("findings"))
52
+ rescue JSON::ParserError, EncodingError
53
+ nil
54
+ end
55
+
56
+ def valid_report?(report)
57
+ return false unless report.is_a?(Hash)
58
+
59
+ findings = report["findings"]
60
+ summary = report["summary"]
61
+ findings.is_a?(Array) &&
62
+ findings.all? { valid_finding?(_1) } &&
63
+ summary.is_a?(Hash) &&
64
+ valid_summary?(summary, findings.length)
65
+ end
66
+
67
+ def valid_finding?(finding)
68
+ return false unless finding.is_a?(Hash)
69
+
70
+ string_fields = %w[tool file rule severity message]
71
+ string_fields.all? { finding[_1].is_a?(String) } &&
72
+ finding["line"].is_a?(Integer) &&
73
+ finding["line"] >= 0 &&
74
+ %w[error warning info].include?(finding["severity"]) &&
75
+ finding["rule"] != "tool_failure"
76
+ end
77
+
78
+ def valid_summary?(summary, finding_count)
79
+ summary_findings = summary["findings"]
80
+ tool_failures = summary["tool_failures"]
81
+ failed_tools = summary["failed_tools"]
82
+
83
+ summary_findings.is_a?(Integer) &&
84
+ summary_findings == finding_count &&
85
+ tool_failures.is_a?(Integer) &&
86
+ tool_failures.zero? &&
87
+ failed_tools == []
88
+ end
89
+
90
+ def outcome_for(exit_status, findings)
91
+ return "clean" if exit_status&.zero? && findings.empty?
92
+
93
+ "findings" if exit_status == 1 && findings.any?
94
+ end
95
+
96
+ def ruby_changes?(project_root)
97
+ stdout, _stderr, status = Open3.capture3(
98
+ "git", "status", "--porcelain", "--untracked-files=all",
99
+ chdir: project_root
100
+ )
101
+ return true unless status.success?
102
+
103
+ ruby_path_in_status?(stdout)
104
+ rescue StandardError
105
+ true
106
+ end
107
+
108
+ def ruby_path_in_status?(status_output)
109
+ lines = status_output.b.split("\n".b, -1)
110
+ lines.pop if lines.last == "".b
111
+
112
+ lines.each do |line|
113
+ paths = status_paths(line)
114
+ return true unless paths
115
+ return true if paths.any? { _1.end_with?(".rb".b) }
116
+ end
117
+ false
118
+ end
119
+
120
+ def status_paths(line)
121
+ return unless line.bytesize >= 4
122
+
123
+ index_status = line.getbyte(0)
124
+ worktree_status = line.getbyte(1)
125
+ return unless valid_status_pair?(index_status, worktree_status) && line.getbyte(2) == " ".ord
126
+
127
+ field = line.byteslice(3, line.bytesize - 3)
128
+ return unless field && !field.empty?
129
+
130
+ paths = rename_status?(index_status, worktree_status) ? renamed_paths(field) : ordinary_path(field)
131
+ return unless paths
132
+
133
+ index_status == "!".ord && worktree_status == "!".ord ? [] : paths
134
+ end
135
+
136
+ def valid_status_pair?(index_status, worktree_status)
137
+ return false unless STATUS_CODES.include?(index_status) && STATUS_CODES.include?(worktree_status)
138
+ return index_status == "?".ord && worktree_status == "?".ord if [index_status, worktree_status].include?("?".ord)
139
+ return index_status == "!".ord && worktree_status == "!".ord if [index_status, worktree_status].include?("!".ord)
140
+
141
+ index_status != " ".ord || worktree_status != " ".ord
142
+ end
143
+
144
+ def rename_status?(index_status, worktree_status)
145
+ [index_status, worktree_status].any? { _1 == "R".ord || _1 == "C".ord }
146
+ end
147
+
148
+ def ordinary_path(field)
149
+ return if rename_separator_offsets(field).any?
150
+
151
+ path = decoded_status_path(field)
152
+ [path] if path
153
+ end
154
+
155
+ def renamed_paths(field)
156
+ offsets = rename_separator_offsets(field)
157
+ return unless offsets.one?
158
+
159
+ separator = offsets.first
160
+ old_path = decoded_status_path(field.byteslice(0, separator))
161
+ new_offset = separator + 4
162
+ new_path = decoded_status_path(field.byteslice(new_offset, field.bytesize - new_offset))
163
+ [old_path, new_path] if old_path && new_path
164
+ end
165
+
166
+ def rename_separator_offsets(field)
167
+ offsets = []
168
+ quoted = false
169
+ index = 0
170
+
171
+ while index < field.bytesize
172
+ byte = field.getbyte(index)
173
+ if quoted && byte == "\\".ord
174
+ return [] if index + 1 >= field.bytesize
175
+
176
+ index += 2
177
+ next
178
+ end
179
+ quoted = !quoted if byte == '"'.ord
180
+ if !quoted && field.byteslice(index, 4) == " -> ".b
181
+ offsets << index
182
+ index += 4
183
+ next
184
+ end
185
+ index += 1
186
+ end
187
+
188
+ quoted ? [] : offsets
189
+ end
190
+
191
+ def decoded_status_path(field)
192
+ return unless field && !field.empty?
193
+ return field unless field.start_with?('"'.b)
194
+
195
+ decode_quoted_status_path(field)
196
+ end
197
+
198
+ def decode_quoted_status_path(field)
199
+ return unless field.bytesize >= 2 && field.end_with?('"'.b)
200
+
201
+ decoded = String.new(encoding: Encoding::BINARY)
202
+ index = 1
203
+ finish = field.bytesize - 1
204
+ while index < finish
205
+ byte = field.getbyte(index)
206
+ return if byte == '"'.ord
207
+
208
+ if byte == "\\".ord
209
+ escaped, index = decode_status_escape(field, index + 1, finish)
210
+ return unless escaped
211
+
212
+ decoded << escaped
213
+ else
214
+ decoded << byte
215
+ index += 1
216
+ end
217
+ end
218
+ decoded
219
+ end
220
+
221
+ def decode_status_escape(field, index, finish)
222
+ return [nil, index] if index >= finish
223
+
224
+ byte = field.getbyte(index)
225
+ return [C_ESCAPES.fetch(byte), index + 1] if C_ESCAPES.key?(byte)
226
+ return [nil, index] unless byte.between?("0".ord, "7".ord)
227
+
228
+ digits = String.new(encoding: Encoding::BINARY)
229
+ 3.times do
230
+ break if index >= finish || !field.getbyte(index).between?("0".ord, "7".ord)
231
+
232
+ digits << field.getbyte(index)
233
+ index += 1
234
+ end
235
+ [digits.to_i(8).chr(Encoding::BINARY), index]
236
+ end
237
+
238
+ def close_log_descriptor(io)
239
+ io&.close
240
+ rescue StandardError
241
+ nil
242
+ end
243
+
244
+ def hook_log_history(project_root)
245
+ root_flags = File::RDONLY | File::NOFOLLOW | HookLogFilesystem::O_NONBLOCK |
246
+ HookLogFilesystem::O_DIRECTORY
247
+ root = File.open(project_root, root_flags)
248
+ root.close_on_exec = true if root.respond_to?(:close_on_exec=)
249
+ return [[], false] unless root.stat.directory?
250
+
251
+ begin
252
+ directory = HookLogFilesystem.open_existing_log_directory(root)
253
+ rescue Errno::ENOENT
254
+ return [[], true]
255
+ end
256
+ return [[], false] unless directory.stat.directory?
257
+
258
+ begin
259
+ log = HookLogFilesystem.open_log_file_for_read(directory)
260
+ rescue Errno::ENOENT
261
+ return [[], true]
262
+ end
263
+ log_stat = log.stat
264
+ return [[], false] unless log_stat.file? && log_stat.nlink == 1
265
+
266
+ log.binmode
267
+ [valid_hook_log_records(log), true]
268
+ rescue LoadError, NotImplementedError, StandardError
269
+ [[], false]
270
+ ensure
271
+ close_log_descriptor(log)
272
+ close_log_descriptor(directory)
273
+ close_log_descriptor(root)
274
+ end
275
+
276
+ def valid_hook_log_records(log)
277
+ hook_log_tail(log).split("\n".b).filter_map { parse_hook_log_record(_1) }
278
+ end
279
+
280
+ def hook_log_tail(log)
281
+ size = log.stat.size
282
+ length = [size, HOOK_LOG_MAX_SCAN_BYTES].min
283
+ offset = size - length
284
+ starts_at_boundary = hook_log_line_boundary?(log, offset)
285
+ log.seek(offset, IO::SEEK_SET)
286
+ bytes = log.read(length) || "".b
287
+ return bytes if starts_at_boundary
288
+
289
+ newline = bytes.index("\n".b)
290
+ newline ? bytes.byteslice(newline + 1, bytes.bytesize - newline - 1) : "".b
291
+ end
292
+
293
+ def hook_log_line_boundary?(log, offset)
294
+ return true if offset.zero?
295
+
296
+ log.seek(offset - 1, IO::SEEK_SET)
297
+ log.read(1) == "\n".b
298
+ end
299
+
300
+ def parse_hook_log_record(line)
301
+ return if line.bytesize > HOOK_LOG_MAX_LINE_BYTES
302
+
303
+ line = line.dup.force_encoding(Encoding::UTF_8)
304
+ return unless line.valid_encoding?
305
+
306
+ record = JSON.parse(line)
307
+ record if valid_hook_log_record?(record)
308
+ rescue JSON::ParserError
309
+ nil
310
+ end
311
+
312
+ def valid_hook_log_record?(record)
313
+ record.is_a?(Hash) &&
314
+ record["ts"].is_a?(String) &&
315
+ record.key?("file") &&
316
+ (record["file"].nil? || record["file"].is_a?(String)) &&
317
+ HOOK_LOG_OUTCOMES.include?(record["outcome"]) &&
318
+ valid_hook_log_duration?(record["duration_ms"])
319
+ end
320
+
321
+ def valid_hook_log_duration?(duration)
322
+ duration.is_a?(Numeric) && duration.finite? && duration >= 0
323
+ end
324
+
325
+ def debounced?(records, session_id)
326
+ latest_verify = records.reverse_each.find do |record|
327
+ record["session_id"] == session_id && DEBOUNCE_DECISIVE_OUTCOMES.include?(record["outcome"])
328
+ end
329
+ return false unless latest_verify&.fetch("outcome") == "verify_clean"
330
+
331
+ clean_time = Time.iso8601(latest_verify.fetch("ts"))
332
+ records.none? { ruby_edit_at_or_after?(_1, clean_time) }
333
+ rescue ArgumentError
334
+ false
335
+ end
336
+
337
+ def ruby_edit_at_or_after?(record, clean_time)
338
+ file = record["file"]
339
+ return false unless file&.end_with?(".rb") && FAST_OUTCOMES.include?(record["outcome"])
340
+
341
+ Time.iso8601(record.fetch("ts")) >= clean_time
342
+ rescue ArgumentError
343
+ true
344
+ end
345
+
346
+ def consecutive_blocks(records, session_id)
347
+ count = 0
348
+ records.reverse_each do |record|
349
+ next unless record["session_id"] == session_id && VERIFY_OUTCOMES.include?(record["outcome"])
350
+
351
+ break unless record["outcome"] == "verify_blocked"
352
+
353
+ count += 1
354
+ end
355
+ count
356
+ end
357
+
358
+ def append_hook_log(project_root, entry)
359
+ root_flags = File::RDONLY | File::NOFOLLOW | HookLogFilesystem::O_NONBLOCK |
360
+ HookLogFilesystem::O_DIRECTORY
361
+ root = File.open(project_root, root_flags)
362
+ root.close_on_exec = true if root.respond_to?(:close_on_exec=)
363
+ return false unless root.stat.directory?
364
+
365
+ directory = HookLogFilesystem.open_log_directory(root)
366
+ return false unless directory.stat.directory?
367
+
368
+ log, created = HookLogFilesystem.open_log_file(directory)
369
+ log_stat = log.stat
370
+ return false unless log_stat.file? && log_stat.nlink == 1
371
+
372
+ if created
373
+ mask = File.umask
374
+ begin
375
+ log.chmod(0o644 & ~mask)
376
+ ensure
377
+ File.umask(mask)
378
+ end
379
+ end
380
+ line = "#{entry}\n"
381
+ written = log.syswrite(line)
382
+ fail IOError, "short hook log write" unless written == line.bytesize
383
+
384
+ true
385
+ rescue LoadError, NotImplementedError, StandardError
386
+ false
387
+ ensure
388
+ close_log_descriptor(log)
389
+ close_log_descriptor(directory)
390
+ close_log_descriptor(root)
391
+ end
392
+
393
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
394
+ input = begin
395
+ parsed = JSON.parse($stdin.read)
396
+ parsed.is_a?(Hash) ? parsed : {}
397
+ rescue JSON::ParserError, EncodingError
398
+ {}
399
+ end
400
+
401
+ project_root = input["cwd"]
402
+ unless project_root.is_a?(String) && !project_root.empty?
403
+ project_root = ENV["CLAUDE_PROJECT_DIR"]
404
+ project_root = Dir.pwd unless project_root.is_a?(String) && !project_root.empty?
405
+ end
406
+ session_id = input["session_id"]
407
+ session_id = "unknown" unless session_id.is_a?(String) && !session_id.empty?
408
+
409
+ exit_code = 0
410
+ feedback = nil
411
+ if !ruby_changes?(project_root)
412
+ outcome = "verify_skipped"
413
+ else
414
+ records, history_available = hook_log_history(project_root)
415
+ if debounced?(records, session_id)
416
+ outcome = "verify_debounced"
417
+ elsif consecutive_blocks(records, session_id) >= BLOCK_LIMIT
418
+ outcome = "verify_cap"
419
+ else
420
+ begin
421
+ stdout, _stderr, status = Open3.capture3(
422
+ "bundle", "exec", "quality_gate", "verify", "--format", "json",
423
+ chdir: project_root
424
+ )
425
+ verify_outcome = quality_gate_outcome(stdout, status)
426
+ if verify_outcome == "clean"
427
+ outcome = "verify_clean"
428
+ elsif verify_outcome == "findings"
429
+ outcome = "verify_blocked"
430
+ exit_code = 2
431
+ feedback = stdout
432
+ else
433
+ outcome = "verify_unavailable"
434
+ end
435
+ rescue StandardError
436
+ outcome = "verify_unavailable"
437
+ end
438
+ end
439
+ end
440
+
441
+ if outcome == "verify_blocked" && !history_available
442
+ outcome = "verify_unavailable"
443
+ feedback = nil
444
+ exit_code = 0
445
+ end
446
+
447
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
448
+ entry = JSON.generate(
449
+ "ts" => Time.now.utc.iso8601(6),
450
+ "file" => nil,
451
+ "outcome" => outcome,
452
+ "duration_ms" => duration_ms,
453
+ "session_id" => session_id
454
+ )
455
+
456
+ persisted = append_hook_log(project_root, entry)
457
+ if outcome == "verify_blocked" && !persisted
458
+ feedback = nil
459
+ exit_code = 0
460
+ end
461
+
462
+ $stderr.write(feedback) if feedback
463
+ exit exit_code
464
+
465
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
466
+ # rubocop:enable Metrics/PerceivedComplexity, Style/SignalException
@@ -0,0 +1 @@
1
+ inherit_gem: { quality_gate: config/rubocop.yml }