recheck 0.0.1 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8578dce5048ae30dfaf640ab2a34397adb449291a22d2e22183a8f89c6d9c6ea
4
- data.tar.gz: 0411c8f0155ac01aba056dee3bb653f50c90f9765f8cbb8b56f961e3f3c89e5b
3
+ metadata.gz: 229b7b48547c7da457bff99e2d10cef1d46a915ad4f3699dd0ae3ad29b7075af
4
+ data.tar.gz: 55d665725da5fe601a0c86ba9c4f62943702b56c7ae239aa3c8c1fe31e01b94c
5
5
  SHA512:
6
- metadata.gz: 8283f42451ce0ac8d7d8d91c7d5c68e860dfa38a10e34b2dd69068b8dbd0dd2abdf5f6f61f06104b3fb3c3c614179b7688131f38da2167f1da263bef47a8af12
7
- data.tar.gz: 773a296cc7d2ba85ec8b265f42936217098d290b02f89ac3c974ee0dcd8f64d95c7af359784f1305073975db0e32c61a6f8e920e48eb2a800b1908420ce4d8c2
6
+ metadata.gz: b2b403ea3658c20f66b825f1198c3c6ea341161b84face61a83529c284135f230b069bd4db13f2550dde0e7965944ac36d3de56b3ec0cb4b579a99796e16dc5a
7
+ data.tar.gz: 1dbfd3a4dd3252b9e8603a9b9bbb28fb35255acdb4d068dfe74d55fde7e0683b53ff66b59c89eae29848070193a0ea48c8da51a78195e88ede9e3f8c35b6039a
data/exe/recheck ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift(
4
+ "#{__dir__}/../lib"
5
+ )
6
+
7
+ require "recheck"
8
+ Recheck::Cli.new(argv: ARGV).run
@@ -0,0 +1,33 @@
1
+ module Recheck
2
+ module Checker
3
+ class Base
4
+ class << self
5
+ def checker_classes
6
+ @@checker_classes ||= Set.new
7
+ @@checker_classes
8
+ end
9
+
10
+ def inherited(klass)
11
+ register klass
12
+ end
13
+
14
+ # Call if you don't want to inherit from Recheck::Checker
15
+ def register klass
16
+ checker_classes << klass
17
+ end
18
+ end
19
+
20
+ # Reflect for a list of queries to run. Override this if you don't want to start all your
21
+ # query methods with `query` or you are metaprogramming query methods at runtime.
22
+ def self.query_methods
23
+ public_instance_methods(false).select { |m| m.to_s.start_with?("query") }
24
+ end
25
+
26
+ # Reflect for a list of checks to run. Override this if you don't want to start all your
27
+ # check methods with `check` or you are metaprogramming check methods at runtime.
28
+ def self.check_methods
29
+ public_instance_methods(false).select { |m| m.to_s.start_with?("check") }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,47 @@
1
+ module Recheck
2
+ class Cli
3
+ EXIT_CODE = {
4
+ no_errors: 0, # all checks passed
5
+ any_errors: 1, # any check returns fail or threw an exception
6
+ load_error: 2, # error loading checker/reporter
7
+ recheck_error: 3 # recheck itself encountered an error
8
+ }.freeze
9
+
10
+ COMMANDS = {
11
+ reporters: "List available reporters",
12
+ run: "Run checks",
13
+ setup: "Set up a new check suite in the current directory"
14
+ }.freeze
15
+
16
+ def initialize(argv: [])
17
+ @argv = argv
18
+ end
19
+
20
+ def run
21
+ global_options = Recheck::Optimist.options(@argv) do
22
+ version "recheck v#{Recheck::VERSION}"
23
+
24
+ banner "Usage:"
25
+ banner " recheck [global options] [<command> [options]]"
26
+
27
+ banner "\nGlobal options:"
28
+ opt :version, "Print version and exit", short: :v
29
+ opt :help, "Print help", short: :h
30
+ stop_on COMMANDS.keys.map(&:to_s)
31
+
32
+ banner "\nCommands:"
33
+ COMMANDS.each { |c, desc| banner format(" %-10s %s", c, desc) }
34
+ end
35
+
36
+ command = global_options[:_leftovers].shift&.to_sym || :help
37
+ Recheck::Optimist.die "unknown command '#{command}'" unless COMMANDS.include? command
38
+
39
+ Recheck::Command.const_get(command.to_s.split("_").map(&:capitalize).join("")).new(argv: global_options[:_leftovers]).run
40
+
41
+ exit EXIT_CODE[:no_errors]
42
+ rescue Interrupt
43
+ puts "\nOperation cancelled by user."
44
+ exit EXIT_CODE[:recheck_error]
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "erb"
5
+
6
+ module Recheck
7
+ module Command
8
+ class Reporters
9
+ def initialize(argv: [])
10
+ @options = Optimist.options(argv) do
11
+ banner "recheck list_reporters: load and list reporters"
12
+ opt :location, "Show source location", short: :l, type: :boolean, default: false
13
+ end
14
+ end
15
+
16
+ def run
17
+ puts "Available reporters (add yours to recheck/reporter/):\n"
18
+ Recheck::Reporter::Base.subclasses.each do |reporter_class|
19
+ help = reporter_class.respond_to?(:help) ? reporter_class.help : nil
20
+ help ||= "No help avalable"
21
+ puts "#{reporter_class.name} #{help}"
22
+ puts %( #{Object.const_source_location(reporter_class.to_s).join(":")}) if @options[:location]
23
+ end
24
+ end
25
+ end # Reporters
26
+
27
+ class Run
28
+ def initialize(argv: [])
29
+ @options = {
30
+ reporters: []
31
+ }
32
+ @file_patterns = []
33
+
34
+ @argv = argv
35
+ @options = Optimist.options(@argv) do
36
+ banner "recheck run: run the suite"
37
+ opt :reporter, "<reporter>[:ARGS], can use multiple times", short: :r, multi: true, default: ["Recheck::Reporter::Default"]
38
+ end
39
+ @files_created = []
40
+
41
+ @file_patterns = @options[:_leftovers]
42
+ end
43
+
44
+ def run
45
+ checkers = load_checkers
46
+ reporters = load_reporters(@options[:reporter])
47
+
48
+ total_counts = Runner.new(checkers:, reporters:).run
49
+ rescue Interrupt
50
+ puts "\nOperation interrupted by user."
51
+ rescue => e
52
+ puts "\nAn error occurred in Recheck:"
53
+ puts e.full_message(highlight: false)
54
+ exit Cli::EXIT_CODE[:recheck_error]
55
+ # ensure
56
+ # puts "ensure"
57
+ # exit Cli::EXIT_CODE[total_counts&.all_pass? ? :no_errors : :any_errors]
58
+ end
59
+
60
+ def load_checkers
61
+ files = if @file_patterns.empty?
62
+ Dir.glob("recheck/**/*.rb").sort
63
+ else
64
+ check_missing_files
65
+ @file_patterns.flat_map do |pattern|
66
+ if File.directory?(pattern)
67
+ Dir.glob(File.join(pattern, "**/*.rb"))
68
+ else
69
+ Dir.glob(pattern)
70
+ end
71
+ end
72
+ end
73
+
74
+ files.each do |file|
75
+ # bug: if the file has a syntax error, Ruby silently exits instead of raising SyntaxError
76
+ require File.expand_path(file)
77
+ rescue LoadError => e
78
+ puts "Loading #{file} threw an exception: #{e.class}: #{e.message}, #{e.backtrace.first}"
79
+ unless file.start_with?("recheck/")
80
+ puts "that filename doesn't start with \"recheck/\", did you give the name of a model instead of its checker?"
81
+ end
82
+ exit Cli::EXIT_CODE[:load_error]
83
+ end
84
+
85
+ if Recheck::Checker::Base.checker_classes.empty?
86
+ error = "No checks detected." +
87
+ (@file_patterns.empty? ? " Did you run `bundle exec recheck setup`?" : "")
88
+ warn error
89
+ exit Cli::EXIT_CODE[:load_error]
90
+ end
91
+
92
+ Recheck::Checker::Base.checker_classes.map(&:new)
93
+ end
94
+
95
+ def check_missing_files
96
+ missing_files = @file_patterns.reject { |pattern| Dir.glob(pattern).any? }
97
+ unless missing_files.empty?
98
+ puts "Error: The following files do not exist:"
99
+ missing_files.each { |file| puts file }
100
+ exit Cli::EXIT_CODE[:load_error]
101
+ end
102
+ end
103
+
104
+ def load_reporters(reporters)
105
+ reporters.map { |option|
106
+ class_name, arg = option.split(/(?<!:):(?!:)/, 2)
107
+ resolve_reporter_class(class_name).new(arg:)
108
+ }
109
+ rescue ArgumentError => e
110
+ puts "Bad argument to Reporter (#{e.backtrace.first}): #{e.message}"
111
+ exit Cli::EXIT_CODE[:load_error]
112
+ rescue LoadError => e
113
+ puts "Loading #{file} threw an exception: #{e.class}: #{e.message}, #{e.backtrace.first}"
114
+ exit Cli::EXIT_CODE[:load_error]
115
+ end
116
+
117
+ def resolve_reporter_class(reporter_name)
118
+ [Object, Recheck::Reporter].each do |namespace|
119
+ return namespace.const_get(reporter_name)
120
+ rescue NameError
121
+ next
122
+ end
123
+ puts "Error: Reporter class '#{reporter_name}' not found globally or in Recheck::Reporter."
124
+ exit Cli::EXIT_CODE[:load_error]
125
+ end
126
+ end # Run
127
+
128
+ class Setup
129
+ def initialize(argv: [])
130
+ @argv = argv
131
+ @options = Optimist.options(@argv) do
132
+ banner "recheck setup: create a check suite"
133
+ end
134
+ @files_created = []
135
+ end
136
+
137
+ def run
138
+ create_helper
139
+ create_samples
140
+ create_site_checks
141
+ setup_model_checks
142
+ run_linter
143
+ vcs_message
144
+ end
145
+
146
+ def run_linter
147
+ if (linter_command = detect_linter)
148
+ puts "Detected linter, running `#{linter_command}` on created files..."
149
+ system("#{linter_command} #{@files_created.join(" ")}")
150
+ end
151
+ end
152
+
153
+ def detect_linter
154
+ return "bundle exec standardrb --fix-unsafely recheck" if File.exist?(".standard.yml") || gemfile_includes?("standard")
155
+ return "bundle exec rubocop --autocorrect-all recheck" if File.exist?(".rubocop.yml") || gemfile_includes?("rubocop")
156
+ nil
157
+ end
158
+
159
+ def gemfile_includes?(gem_name)
160
+ File.readlines("Gemfile").any? { |line| line.include?(gem_name) }
161
+ rescue Errno::ENOENT
162
+ false
163
+ end
164
+
165
+ private
166
+
167
+ def create_helper
168
+ copy_template("#{template_dir}/recheck_helper.rb", "recheck/recheck_helper.rb")
169
+ end
170
+
171
+ def create_samples
172
+ copy_template("#{template_dir}/reporter_sample.rb", "recheck/reporter/reporter.rb.sample")
173
+ copy_template("#{template_dir}/regression_checker_sample.rb", "recheck/regression/regression_checker.rb.sample")
174
+ end
175
+
176
+ def create_site_checks
177
+ Dir.glob("#{template_dir}/site/*.rb").each do |filename|
178
+ copy_template(filename, "recheck/site/#{File.basename(filename)}")
179
+ end
180
+ end
181
+
182
+ ModelFile = Data.define(:path, :class_name, :readonly, :pk_info) do
183
+ def checker_path
184
+ "recheck/model/#{underscore(class_name.gsub("::", "/"))}_checker.rb"
185
+ end
186
+
187
+ def underscore(string)
188
+ string.gsub("::", "/")
189
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
190
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
191
+ .tr("-", "_")
192
+ .downcase
193
+ end
194
+ end
195
+
196
+ # warning: the rails gem overrides this method because it can
197
+ # provide better checks with the rails env loaded
198
+ def setup_model_checks
199
+ puts "Scanning for ActiveRecord models..."
200
+ model_files.each do |mf|
201
+ if mf.readonly
202
+ puts " #{mf.path} -> Skipped (readonly model, likely a view or uneditable record)"
203
+ elsif mf.pk_info.nil?
204
+ puts " #{mf.path} -> Skipped (model without primary key, unable to report on it)"
205
+ else
206
+ FileUtils.mkdir_p(File.dirname(mf.checker_path))
207
+ File.write(mf.checker_path, model_check_content(mf.class_name, mf.pk_info))
208
+ @files_created << mf.checker_path
209
+ puts " #{mf.path} -> #{mf.checker_path}"
210
+ end
211
+ end
212
+ end
213
+
214
+ def model_files
215
+ search_paths = Dir.glob("**/*.rb").reject { |f| f.start_with?(%r{db/migrate/|vendor/}) }
216
+ search_paths.map do |path|
217
+ content = File.read(path)
218
+ if content.match?(/class\s+\w+(::\w+)*\s+<\s+(ApplicationRecord|ActiveRecord::Base)/) &&
219
+ !content.match?(/^\s+self\.abstract_class\s*=\s*true/)
220
+ class_name = extract_class_name(content)
221
+ readonly = readonly_model?(content)
222
+ pk_info = extract_primary_key_info(content)
223
+ ModelFile.new(path, class_name, readonly, pk_info)
224
+ end
225
+ end.compact
226
+ rescue Errno::ENOENT => e
227
+ puts "Error reading file: #{e.message}"
228
+ []
229
+ end
230
+
231
+ PrimaryKeyInfo = Data.define(:query_method, :fetch_id_code) do
232
+ def compound?
233
+ fetch_id_code.include?(" + ")
234
+ end
235
+ end
236
+
237
+ def extract_primary_key_info(content)
238
+ if content.match?(/self\.primary_key\s*=/)
239
+ pk_definition = content.match(/self\.primary_key\s*=\s*(.+)$/)[1].strip
240
+ if pk_definition.start_with?("[")
241
+ keys = parse_array(pk_definition)
242
+ fetch_id_code = keys.map { |key| "record.#{key}" }.join(' + "-" + ')
243
+ else
244
+ key = parse_symbol_or_string(pk_definition)
245
+ fetch_id_code = "record.#{key}.to_s"
246
+ end
247
+ query_method = ".all"
248
+ elsif content.match?(/self\.primary_key\s*=\s*nil/)
249
+ return nil
250
+ else
251
+ fetch_id_code = "record.id.to_s"
252
+ query_method = ".find_each"
253
+ end
254
+ PrimaryKeyInfo.new(query_method: query_method, fetch_id_code: fetch_id_code)
255
+ end
256
+
257
+ def parse_array(str)
258
+ str.gsub(/[\[\]]/, "").split(",").map { |item| parse_symbol_or_string(item.strip) }
259
+ end
260
+
261
+ def parse_symbol_or_string(str)
262
+ if str.start_with?(":")
263
+ str[1..].to_sym
264
+ elsif str.start_with?('"', "'")
265
+ str[1..-2]
266
+ else
267
+ str
268
+ end
269
+ end
270
+
271
+ def active_record_model?(content)
272
+ content.match?(/class\s+\w+(::\w+)*\s+<\s+(ApplicationRecord|ActiveRecord::Base)/) &&
273
+ !content.match?(/^\s+self\.abstract_class\s*=\s*true/) &&
274
+ !readonly_model?(content)
275
+ end
276
+
277
+ def readonly_model?(content)
278
+ content.match?(/^\s*def\s+readonly\?\s*true\s*end/) ||
279
+ content.match?(/^\s*def\s+readonly\?\s*$\s*true\s*end/m)
280
+ end
281
+
282
+ def extract_class_name(content)
283
+ content.match(/class\s+(\w+(::\w+)*)\s+</)[1]
284
+ end
285
+
286
+ def copy_template(from, to)
287
+ content = File.read(from)
288
+ FileUtils.mkdir_p(File.dirname(to))
289
+ File.write(to, content)
290
+ @files_created << to
291
+ puts "Created: #{to}"
292
+ rescue Errno::ENOENT => e
293
+ puts "Error creating file: #{e.message}"
294
+ end
295
+
296
+ def model_check_content(class_name, pk_info)
297
+ template = File.read("#{template_dir}/active_record_model_check.rb.erb")
298
+ ERB.new(template).result(binding)
299
+ end
300
+
301
+ # surely there's a better way to find the gem's root
302
+ def template_dir
303
+ File.join(File.expand_path("../..", __dir__), "template")
304
+ end
305
+
306
+ def vcs_message
307
+ puts
308
+ puts "Run `git add --all` and `git commit` to checkpoint this setup, then `bundle exec recheck run` to check for the first time."
309
+ end
310
+ end # Setup
311
+ end
312
+ end
@@ -0,0 +1,66 @@
1
+ module Recheck
2
+ class CountStats
3
+ attr_reader :counts, :queries
4
+
5
+ Recheck::RESULT_TYPES.each do |type|
6
+ define_method(type) do
7
+ @counts[type]
8
+ end
9
+ end
10
+
11
+ def initialize
12
+ @counts = RESULT_TYPES.map { [it, 0] }.to_h
13
+ @queries = 0
14
+ end
15
+
16
+ def <<(other)
17
+ @queries += other.queries
18
+ @counts.merge!(other.counts) { |type, self_v, other_v| self_v + other_v }
19
+ self
20
+ end
21
+
22
+ def all_pass?
23
+ @counts.slice(*Recheck::ERROR_TYPES).all? { |type, count| count.zero? }
24
+ end
25
+
26
+ def all_zero?
27
+ @counts.all? { |type, count| count.zero? }
28
+ end
29
+
30
+ def any_errors?
31
+ !all_pass?
32
+ end
33
+
34
+ def increment(type)
35
+ if type == :queries
36
+ @queries += 1
37
+ elsif !@counts.include? type
38
+ raise ArgumentError, "Unkown type #{type}"
39
+ else
40
+ @counts[type] += 1
41
+ end
42
+ end
43
+
44
+ def reached_blanket_failure?
45
+ pass == 0 && (fail == 20 || exception == 20)
46
+ end
47
+
48
+ def summary
49
+ "#{queries} #{(queries == 1) ? "query" : "queries"}, " + (
50
+ [:pass, :fail] +
51
+ [
52
+ :exception,
53
+ :blanket,
54
+ :no_query_methods,
55
+ :no_queries,
56
+ :no_check_methods,
57
+ :no_checks
58
+ ].filter { |type| @counts[type].nonzero? }
59
+ ).map { |type| "#{@counts[type]} #{type}" }.join(", ")
60
+ end
61
+
62
+ def total
63
+ @counts.values.sum
64
+ end
65
+ end
66
+ end