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,15 @@
1
+ <!-- quality_gate agent contract — start -->
2
+ ## Quality Gate agent contract
3
+
4
+ Run `bundle exec quality_gate fast` after Ruby edits. Installed Claude Code hooks can run `bundle exec quality_gate verify` automatically at session end; other clients, including Codex, must run `bundle exec quality_gate verify` manually before you finish a change. Run `bundle exec quality_gate audit` before you merge security-sensitive work.
5
+
6
+ Fix findings before you continue editing.
7
+
8
+ - Exit 0 means the gate ran clean.
9
+ - Exit 1 means the gate reported findings and no tool failures.
10
+ - Exit 2 means the gate could not complete cleanly because of invalid input, configuration, or another tool failure.
11
+
12
+ The default fast path keeps feedback tight with a 10-second RuboCop timeout. Verify and audit keep the broader suite work on the 120-second default timeout.
13
+
14
+ To reinstall the Ruby profile's agent integration, run `bundle exec quality_gate init --profile ruby --agents`.
15
+ <!-- quality_gate agent contract — end -->
@@ -0,0 +1,40 @@
1
+ # # Output format: "text" or "json".
2
+ # format: text
3
+ #
4
+ # # Optional file paths passed to RuboCop and Reek; empty means the project scope.
5
+ # files: []
6
+ #
7
+ # # Plain Ruby adapters run by each gate, in order.
8
+ adapters:
9
+ fast:
10
+ - rubocop
11
+ verify:
12
+ - reek
13
+ - test_suite
14
+ <% if @profile.coverage? %> - undercover
15
+ <% end %> audit:
16
+ - bundler_audit
17
+
18
+ # # Command overrides by gate.
19
+ commands:
20
+ fast: {}
21
+ verify:
22
+ test_suite:
23
+ <% @profile.test_command.each do |argument| %> - <%= JSON.generate(argument) %>
24
+ <% end %> audit: {}
25
+
26
+ # # Command timeouts in seconds.
27
+ timeouts:
28
+ default: 120
29
+ rubocop: 10
30
+ test_suite: 120
31
+ undercover: 120
32
+
33
+ # # Aggregate SimpleCov budgets. Leave the entire mapping commented to disable coverage budgeting.
34
+ # coverage:
35
+
36
+ # # Git ref or commit for Undercover; blank selects the comparison point automatically.
37
+ # compare_point:
38
+
39
+ # # Explicit RuboCop config path; blank uses host config when present, otherwise the shipped config.
40
+ # rubocop_config:
@@ -0,0 +1 @@
1
+ inherit_gem: { quality_gate: config/ruby.yml }
@@ -0,0 +1,11 @@
1
+ if ENV['COVERAGE'] == '1'
2
+ require 'simplecov'
3
+ require 'undercover/simplecov_formatter'
4
+
5
+ SimpleCov.formatter = SimpleCov::Formatter::Undercover
6
+ SimpleCov.start do
7
+ enable_coverage :branch
8
+ add_filter '/test/'
9
+ add_filter '/spec/'
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ if ENV['COVERAGE'] == '1'
2
+ require 'simplecov'
3
+ require 'undercover/simplecov_formatter'
4
+
5
+ SimpleCov.formatter = SimpleCov::Formatter::Undercover
6
+ SimpleCov.start do
7
+ enable_coverage :branch
8
+ add_filter '/test/'
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'strong_migrations'
4
+
5
+ <%= strong_migrations_provenance %>
6
+ <% if (version = strong_migrations_baseline) -%>
7
+ StrongMigrations.start_after = <%= migration_version_literal(version) %>
8
+ <% else -%>
9
+ # Set StrongMigrations.start_after after the first migration exists.
10
+ <% end -%>
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "open3"
5
+
6
+ module QualityGate
7
+ # Raised when adapter output cannot be parsed into findings.
8
+ class ParseError < Error
9
+ attr_reader :tool
10
+
11
+ def initialize(tool:, reason:)
12
+ @tool = tool.dup.freeze
13
+ super("Could not parse #{@tool} output: #{reason}")
14
+ end
15
+ end
16
+
17
+ # Executes one quality tool without involving a shell and normalizes failures.
18
+ # rubocop:disable Metrics/ClassLength
19
+ class Adapter
20
+ STDERR_LINE_LIMIT = 20
21
+ TERMINATE_GRACE_SECONDS = 0.2
22
+
23
+ def initialize(config:, files: [], diagnostic_io: $stderr)
24
+ @config = config
25
+ @files = files.map { copy_string(_1) }.freeze
26
+ @diagnostic_io = diagnostic_io
27
+ end
28
+
29
+ def call
30
+ tool = name
31
+ stderr = +""
32
+ stdout, stderr, = capture(validated_command, resolved_timeout(tool))
33
+ findings = parse(stdout)
34
+ validate_findings!(findings, expected_tool: tool)
35
+ findings
36
+ rescue StandardError => e
37
+ [failure_finding(tool, e, stderr)]
38
+ end
39
+
40
+ def name
41
+ raise NotImplementedError, "#{self.class} must implement #name"
42
+ end
43
+
44
+ def command
45
+ raise NotImplementedError, "#{self.class} must implement #command"
46
+ end
47
+
48
+ def parse(_stdout)
49
+ raise NotImplementedError, "#{self.class} must implement #parse"
50
+ end
51
+
52
+ def timeout
53
+ resolved_timeout(name)
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :config, :diagnostic_io, :files
59
+
60
+ def validated_command
61
+ value = command
62
+ return value if value.is_a?(Array) && value.all?(String)
63
+
64
+ raise ArgumentError, "command must be an array of strings"
65
+ end
66
+
67
+ def validate_findings!(findings, expected_tool:)
68
+ raise TypeError, "parse must return an array of findings" unless findings.is_a?(Array)
69
+
70
+ findings.each do |finding|
71
+ raise TypeError, "parse must return finding objects" unless finding.is_a?(Finding)
72
+ unless finding.tool == expected_tool
73
+ raise ArgumentError, "finding tool must match adapter #{expected_tool.inspect}"
74
+ end
75
+
76
+ unless Finding::SEVERITIES.include?(finding.severity)
77
+ raise ArgumentError, "invalid finding severity #{finding.severity.inspect}"
78
+ end
79
+ end
80
+ end
81
+
82
+ def capture(argv, timeout_seconds, env: {}, combine_output: false)
83
+ stdin, stdout, stderr, wait_thread = open_capture(argv, env, combine_output)
84
+ resources = {
85
+ process_group_id: -wait_thread.pid,
86
+ wait_thread:,
87
+ stdin:,
88
+ stdout:,
89
+ stderr:,
90
+ readers: {}
91
+ }
92
+ deadline = monotonic_deadline(timeout_seconds)
93
+
94
+ stdin.close
95
+ register_readers(resources)
96
+
97
+ wait_for_process(wait_thread, timeout_seconds, deadline)
98
+ stdout_text = captured_output(resources, :stdout, timeout_seconds, deadline)
99
+ stderr_text = captured_output(resources, :stderr, timeout_seconds, deadline)
100
+
101
+ [stdout_text, stderr_text, wait_thread.value]
102
+ ensure
103
+ cleanup_capture(resources, $ERROR_INFO)
104
+ end
105
+
106
+ def open_capture(argv, env, combine_output)
107
+ return merged_capture(env, argv) if combine_output
108
+
109
+ Open3.popen3(env, *argv, pgroup: true)
110
+ end
111
+
112
+ def merged_capture(env, argv)
113
+ stdin, output, wait_thread = Open3.popen2e(env, *argv, pgroup: true)
114
+ [stdin, output, nil, wait_thread]
115
+ end
116
+
117
+ def register_readers(resources)
118
+ readers = resources.fetch(:readers)
119
+ readers[:stdout] = reader_thread(resources.fetch(:stdout))
120
+ stderr = resources.fetch(:stderr)
121
+ readers[:stderr] = reader_thread(stderr) if stderr
122
+ end
123
+
124
+ def captured_output(resources, stream, timeout_seconds, deadline)
125
+ reader = resources.fetch(:readers).fetch(stream, nil)
126
+ return +"" unless reader
127
+
128
+ fetch_reader_output(reader, timeout_seconds, deadline)
129
+ end
130
+
131
+ def wait_for_process(wait_thread, timeout_seconds, deadline)
132
+ return if wait_thread.join(remaining_before(deadline))
133
+
134
+ raise TimeoutError, "timeout after #{timeout_seconds} seconds"
135
+ end
136
+
137
+ def fetch_reader_output(reader, timeout_seconds, deadline)
138
+ return reader.value if reader.join(remaining_before(deadline))
139
+
140
+ raise TimeoutError, "timeout after #{timeout_seconds} seconds"
141
+ end
142
+
143
+ # rubocop:disable Metrics/AbcSize
144
+ def cleanup_capture(resources, active_exception)
145
+ return unless resources
146
+
147
+ terminate_process_group(resources, active_exception) if active_exception
148
+ cleanup_action(active_exception) { close_pipe(resources.fetch(:stdin)) }
149
+ cleanup_action(active_exception) { close_pipe(resources.fetch(:stdout)) }
150
+ cleanup_action(active_exception) { close_pipe(resources.fetch(:stderr)) }
151
+ cleanup_action(active_exception) { join_reader(resources.fetch(:readers)&.fetch(:stdout, nil)) }
152
+ cleanup_action(active_exception) { join_reader(resources.fetch(:readers)&.fetch(:stderr, nil)) }
153
+ end
154
+ # rubocop:enable Metrics/AbcSize
155
+
156
+ def terminate_process_group(resources, active_exception)
157
+ process_group_id = resources.fetch(:process_group_id)
158
+ wait_thread = resources.fetch(:wait_thread)
159
+ process_pid = wait_thread.pid
160
+
161
+ cleanup_action(active_exception) { signal_with_fallback("TERM", process_group_id, process_pid) }
162
+ wait_thread.join(TERMINATE_GRACE_SECONDS)
163
+ return unless process_group_alive?(process_group_id)
164
+
165
+ cleanup_action(active_exception) { signal_with_fallback("KILL", process_group_id, process_pid) }
166
+ wait_thread.join if wait_thread&.alive?
167
+ rescue Errno::ESRCH
168
+ wait_thread.join if wait_thread&.alive?
169
+ end
170
+
171
+ def signal_with_fallback(signal, process_group_id, process_pid)
172
+ signal_process_group(signal, process_group_id)
173
+ rescue StandardError
174
+ signal_process(signal, process_pid)
175
+ end
176
+
177
+ def signal_process_group(signal, process_group_id)
178
+ return unless process_group_id
179
+
180
+ Process.kill(signal, process_group_id)
181
+ end
182
+
183
+ def signal_process(signal, process_pid)
184
+ Process.kill(signal, process_pid)
185
+ end
186
+
187
+ def process_group_alive?(process_group_id)
188
+ Process.kill(0, process_group_id)
189
+ true
190
+ rescue Errno::EPERM
191
+ true
192
+ rescue Errno::ESRCH
193
+ false
194
+ end
195
+
196
+ def cleanup_action(active_exception)
197
+ yield
198
+ rescue StandardError
199
+ raise unless active_exception
200
+ end
201
+
202
+ def reader_thread(pipe)
203
+ Thread.new do
204
+ Thread.current.report_on_exception = false
205
+ pipe.read.to_s
206
+ end
207
+ end
208
+
209
+ def close_pipe(pipe)
210
+ pipe.close unless pipe.nil? || pipe.closed?
211
+ rescue IOError, SystemCallError
212
+ nil
213
+ end
214
+
215
+ def join_reader(reader)
216
+ return unless reader
217
+
218
+ reader.join
219
+ reader.value
220
+ end
221
+
222
+ def monotonic_deadline(timeout_seconds)
223
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
224
+ end
225
+
226
+ def remaining_before(deadline)
227
+ [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
228
+ end
229
+
230
+ def failure_message(tool, error, stderr)
231
+ reason = sanitize_fragment(error.message)
232
+ message = "#{tool}: #{reason}"
233
+ stderr_tail = sanitized_stderr_tail(stderr)
234
+ return message if stderr_tail.empty? || !unreadable_output?(error)
235
+
236
+ "#{message} stderr: #{stderr_tail}"
237
+ end
238
+
239
+ def failure_finding(tool, error, stderr)
240
+ safe_tool = safe_failure_tool(tool)
241
+ Finding.tool_failure(tool: safe_tool, message: safe_failure_message(safe_tool, error, stderr))
242
+ rescue StandardError
243
+ Finding.tool_failure(tool: fallback_tool_name, message: "#{fallback_tool_name}: adapter failure")
244
+ end
245
+
246
+ def safe_failure_tool(tool)
247
+ normalized_tool_name(tool || fallback_tool_name)
248
+ rescue StandardError
249
+ fallback_tool_name
250
+ end
251
+
252
+ def safe_failure_message(tool, error, stderr)
253
+ failure_message(tool, error, stderr)
254
+ rescue StandardError
255
+ "#{tool}: adapter failure"
256
+ end
257
+
258
+ def resolved_timeout(tool)
259
+ tool_timeouts = config.fetch(:timeouts)
260
+ raise ArgumentError, "timeouts config must be a mapping" unless tool_timeouts.is_a?(Hash)
261
+
262
+ validate_timeout_value(timeout_value_for(tool_timeouts, tool))
263
+ rescue KeyError
264
+ raise ArgumentError, "timeouts config is missing"
265
+ end
266
+
267
+ def timeout_value_for(tool_timeouts, tool)
268
+ return tool_timeouts.fetch(tool.to_sym) if tool_timeouts.key?(tool.to_sym)
269
+ return tool_timeouts.fetch(:default) if tool_timeouts.key?(:default)
270
+
271
+ raise ArgumentError, "timeout default is missing"
272
+ end
273
+
274
+ def validate_timeout_value(timeout_value)
275
+ return timeout_value if timeout_value.is_a?(Integer) && timeout_value.positive?
276
+
277
+ raise ArgumentError, "timeout must be a positive Integer"
278
+ end
279
+
280
+ def unreadable_output?(error)
281
+ error.is_a?(ParseError)
282
+ end
283
+
284
+ def sanitized_stderr_tail(stderr)
285
+ stderr
286
+ .to_s
287
+ .dup
288
+ .force_encoding(Encoding::UTF_8)
289
+ .scrub("?")
290
+ .lines
291
+ .map { sanitize_fragment(_1) }
292
+ .reject(&:empty?)
293
+ .last(STDERR_LINE_LIMIT)
294
+ .join(" | ")
295
+ end
296
+
297
+ def sanitize_fragment(text)
298
+ text
299
+ .to_s
300
+ .dup
301
+ .force_encoding(Encoding::UTF_8)
302
+ .scrub("?")
303
+ .gsub(/[[:cntrl:]]+/, " ")
304
+ .gsub(/\s+/, " ")
305
+ .strip
306
+ end
307
+
308
+ def fallback_tool_name
309
+ component = self.class.name.to_s.split("::").last.to_s
310
+ tool_name = sanitize_fragment(component).downcase
311
+ tool_name.empty? ? "adapter" : tool_name
312
+ end
313
+
314
+ def normalized_tool_name(tool)
315
+ sanitized = sanitize_fragment(tool)
316
+ sanitized.empty? ? fallback_tool_name : sanitized
317
+ end
318
+
319
+ def copy_string(value)
320
+ value.dup.freeze
321
+ end
322
+
323
+ class TimeoutError < StandardError; end
324
+
325
+ private_constant :STDERR_LINE_LIMIT, :TERMINATE_GRACE_SECONDS, :TimeoutError
326
+ end
327
+ # rubocop:enable Metrics/ClassLength
328
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module QualityGate
6
+ module Adapters
7
+ # Runs Brakeman across the application and normalizes its JSON report.
8
+ class Brakeman < Adapter
9
+ class InvalidReport < StandardError; end
10
+ private_constant :InvalidReport
11
+
12
+ CONFIDENCE_MAP = {
13
+ "High" => :error,
14
+ "Medium" => :warning,
15
+ "Weak" => :info
16
+ }.freeze
17
+
18
+ def call
19
+ tool = name
20
+ stderr = +""
21
+ stdout, stderr, status = capture(validated_command, resolved_timeout(tool))
22
+ findings = parse(stdout)
23
+ validate_findings!(findings, expected_tool: tool)
24
+ validate_cli_result!(status, findings)
25
+ findings
26
+ rescue StandardError => e
27
+ [failure_finding(tool, e, stderr)]
28
+ end
29
+
30
+ def name = "brakeman"
31
+
32
+ def command = ["brakeman", "-f", "json", "-q", "--no-pager"]
33
+
34
+ def parse(stdout)
35
+ report = parse_report(stdout)
36
+ warnings = report.fetch("warnings") { invalid_report!("report must include warnings") }
37
+ errors = report.fetch("errors") { invalid_report!("report must include errors") }
38
+ validate_errors!(errors)
39
+ parse_warnings(warnings)
40
+ rescue JSON::ParserError, InvalidReport => e
41
+ fail ParseError.new(tool: name, reason: e.message) # rubocop:disable Style/SignalException
42
+ end
43
+
44
+ private
45
+
46
+ def parse_report(stdout)
47
+ report = JSON.parse(stdout)
48
+ invalid_report!("report must be a JSON object") unless report.is_a?(Hash)
49
+
50
+ report
51
+ end
52
+
53
+ def parse_warnings(entries)
54
+ invalid_report!("warnings must be an array") unless entries.is_a?(Array)
55
+
56
+ entries.map { build_finding(_1) }
57
+ end
58
+
59
+ def validate_errors!(errors)
60
+ invalid_report!("errors must be an array") unless errors.is_a?(Array)
61
+ invalid_report!("report contains scan errors") unless errors.empty?
62
+ end
63
+
64
+ def validate_cli_result!(status, findings)
65
+ cli_contract_error!("Brakeman did not exit normally") unless status&.exited?
66
+
67
+ exitstatus = status.exitstatus
68
+ return if exitstatus.zero?
69
+ return if exitstatus == 3 && findings.any?
70
+
71
+ if exitstatus == 3
72
+ cli_contract_error!("Brakeman exit status 3 disagrees with its warning count")
73
+ else
74
+ cli_contract_error!("Brakeman exited with unsupported status #{exitstatus.inspect}")
75
+ end
76
+ end
77
+
78
+ def cli_contract_error!(reason)
79
+ fail ParseError.new(tool: name, reason:) # rubocop:disable Style/SignalException
80
+ end
81
+
82
+ def build_finding(warning)
83
+ invalid_report!("warning must be an object") unless warning.is_a?(Hash)
84
+
85
+ Finding.new(tool: name, **finding_attributes(warning))
86
+ end
87
+
88
+ def finding_attributes(warning)
89
+ line = warning_field(warning, "line")
90
+ confidence = string_field(warning, "confidence")
91
+
92
+ {
93
+ file: string_field(warning, "file"),
94
+ line: validated_line(line),
95
+ rule: string_field(warning, "warning_type"),
96
+ severity: CONFIDENCE_MAP.fetch(confidence) { invalid_report!("unknown confidence #{confidence.inspect}") },
97
+ message: string_field(warning, "message")
98
+ }
99
+ end
100
+
101
+ def warning_field(warning, field)
102
+ warning.fetch(field) { invalid_report!("warning must include #{field}") }
103
+ end
104
+
105
+ def string_field(warning, field)
106
+ value = warning_field(warning, field)
107
+ invalid_report!("#{field} must be a String") unless value.is_a?(String)
108
+
109
+ value
110
+ end
111
+
112
+ def validated_line(line)
113
+ return 0 if line.nil?
114
+
115
+ invalid_report!("line must be a non-negative Integer") unless line.is_a?(Integer) && line >= 0
116
+
117
+ line
118
+ end
119
+
120
+ def invalid_report!(reason)
121
+ raise InvalidReport, reason
122
+ end
123
+ end
124
+ end
125
+ end