bullematic 0.1.0 → 0.2.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -1
  3. data/README.md +23 -11
  4. data/exe/bullematic +7 -0
  5. data/lib/bullematic/ast/finder.rb +190 -35
  6. data/lib/bullematic/ast/parser.rb +1 -17
  7. data/lib/bullematic/ast/rewriter.rb +90 -28
  8. data/lib/bullematic/cli.rb +185 -0
  9. data/lib/bullematic/configuration.rb +15 -4
  10. data/lib/bullematic/detection.rb +110 -13
  11. data/lib/bullematic/evidence_store.rb +78 -0
  12. data/lib/bullematic/fixer.rb +258 -31
  13. data/lib/bullematic/integrations/minitest.rb +20 -6
  14. data/lib/bullematic/integrations/rails.rb +23 -9
  15. data/lib/bullematic/integrations/rspec.rb +19 -7
  16. data/lib/bullematic/logger.rb +24 -12
  17. data/lib/bullematic/notifier.rb +131 -36
  18. data/lib/bullematic/version.rb +1 -1
  19. data/lib/bullematic.rb +12 -4
  20. data/sig/generated/bullematic/ast/finder.rbs +70 -11
  21. data/sig/generated/bullematic/ast/parser.rbs +0 -6
  22. data/sig/generated/bullematic/ast/rewriter.rbs +28 -11
  23. data/sig/generated/bullematic/cli.rbs +56 -0
  24. data/sig/generated/bullematic/configuration.rbs +16 -20
  25. data/sig/generated/bullematic/detection.rbs +77 -7
  26. data/sig/generated/bullematic/evidence_store.rbs +35 -0
  27. data/sig/generated/bullematic/fixer.rbs +59 -3
  28. data/sig/generated/bullematic/integrations/rails.rbs +7 -0
  29. data/sig/generated/bullematic/logger.rbs +5 -0
  30. data/sig/generated/bullematic/notifier.rbs +17 -13
  31. data/sig/generated/bullematic.rbs +5 -0
  32. data/sig/stubs/rails.rbs +60 -0
  33. metadata +161 -8
  34. data/Rakefile +0 -26
  35. data/Steepfile +0 -15
  36. data/assets/logo-header.svg +0 -28
  37. data/rbs_collection.lock.yaml +0 -24
  38. data/rbs_collection.yaml +0 -14
@@ -0,0 +1,185 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "tempfile"
6
+
7
+ module Bullematic
8
+ class CLI
9
+ class << self
10
+ # @rbs argv: Array[String]
11
+ # @rbs return: Integer
12
+ def run(argv)
13
+ command = argv.shift
14
+ case command
15
+ when "record" then record(command_args(argv))
16
+ when "plan" then apply(dry_run: true)
17
+ when "apply" then apply(dry_run: false)
18
+ when "verify" then verify(command_args(argv))
19
+ when "fix" then fix(argv)
20
+ when "doctor" then doctor
21
+ else
22
+ warn "Usage: bullematic record -- COMMAND | plan | apply | verify [-- COMMAND] | fix [--verify] -- COMMAND | doctor"
23
+ 1
24
+ end
25
+ rescue Error, SystemCallError, JSON::ParserError => error
26
+ warn "bullematic: #{error.message}"
27
+ 1
28
+ end
29
+
30
+ private
31
+
32
+ # @rbs argv: Array[String]
33
+ # @rbs return: Array[String]
34
+ def command_args(argv)
35
+ argv = argv.dup
36
+ argv.shift if argv.first == "--"
37
+ argv
38
+ end
39
+
40
+ # @rbs command: Array[String]
41
+ # @rbs return: Integer
42
+ def record(command)
43
+ raise Error, "record requires a command" if command.empty?
44
+
45
+ evidence_path = EvidenceStore.path
46
+ EvidenceStore.clear(evidence_path)
47
+ write_command(evidence_path, command)
48
+ run_command(command, evidence_path)
49
+ end
50
+
51
+ # @rbs dry_run: bool
52
+ # @rbs return: Integer
53
+ def apply(dry_run:)
54
+ load_application
55
+ configure(dry_run)
56
+ detections = EvidenceStore.read.select { |detection| detection.type == :n_plus_one }
57
+ raise Error, "no recorded N+1 evidence in #{EvidenceStore.path}" if detections.empty?
58
+
59
+ record_file = ENV.delete(EvidenceStore::ENV_KEY)
60
+ begin
61
+ detections.each { |detection| Fixer.queue(detection) }
62
+ ensure
63
+ ENV[EvidenceStore::ENV_KEY] = record_file if record_file
64
+ end
65
+ stats = Fixer.apply_fixes
66
+ operation = dry_run ? :planned : :fixed
67
+ raise Error, "#{stats[:errors]} error(s) while applying fixes" if stats[:errors].positive?
68
+ raise Error, "no safe fixes were #{operation}" if stats[operation].zero?
69
+
70
+ 0
71
+ end
72
+
73
+ # @rbs argv: Array[String]
74
+ # @rbs return: Integer
75
+ def fix(argv)
76
+ separator = argv.index("--") || argv.length
77
+ verify_index = argv.first(separator).index("--verify")
78
+ verify_after = argv.delete_at(verify_index) if verify_index
79
+ command = command_args(argv)
80
+ status = record(command)
81
+ return status unless status.zero?
82
+
83
+ status = apply(dry_run: false)
84
+ return status unless status.zero? && verify_after
85
+
86
+ verify(command)
87
+ end
88
+
89
+ # @rbs command: Array[String]
90
+ # @rbs return: Integer
91
+ def verify(command)
92
+ load_application
93
+ configure(true)
94
+ original = EvidenceStore.read.select { |detection| detection.type == :n_plus_one }
95
+ raise Error, "no recorded N+1 evidence in #{EvidenceStore.path}" if original.empty?
96
+
97
+ command = read_command(EvidenceStore.path) if command.empty?
98
+ raise Error, "verify requires a recorded command" if command.empty?
99
+
100
+ Tempfile.create(["bullematic-verify", ".jsonl"]) do |file|
101
+ file.close
102
+ status = run_command(command, file.path)
103
+ return status unless status.zero?
104
+
105
+ remaining = EvidenceStore.read(file.path).count { |detection| detection.type == :n_plus_one }
106
+ raise Error, "#{remaining} N+1 warning(s) remain" unless remaining.zero?
107
+ end
108
+
109
+ puts "Bullematic verification passed"
110
+ 0
111
+ end
112
+
113
+ #: () -> Integer
114
+ def doctor
115
+ require "bullet"
116
+ require "prism"
117
+ raise Error, "unsupported Bullet notification API" unless Notifier.compatible?
118
+
119
+ bullet_spec = Gem.loaded_specs.fetch("bullet") #: untyped
120
+ prism_spec = Gem.loaded_specs.fetch("prism") #: untyped
121
+ bullet_version = bullet_spec.version
122
+ prism_version = prism_spec.version
123
+ puts "Bullematic #{VERSION}; Bullet #{bullet_version}; Prism #{prism_version}: compatible"
124
+ 0
125
+ end
126
+
127
+ # @rbs command: Array[String]
128
+ # @rbs evidence_path: String
129
+ # @rbs return: Integer
130
+ def run_command(command, evidence_path)
131
+ environment = { "BULLEMATIC" => "1", EvidenceStore::ENV_KEY => evidence_path }
132
+ system(environment, *command) ? 0 : ($?&.exitstatus || 1)
133
+ end
134
+
135
+ #: () -> void
136
+ def load_application
137
+ environment = File.expand_path("config/environment.rb")
138
+ require environment if File.file?(environment)
139
+ end
140
+
141
+ # @rbs dry_run: bool
142
+ # @rbs return: void
143
+ def configure(dry_run)
144
+ Bullematic.configure do |config|
145
+ config.auto_fix = true
146
+ config.dry_run = dry_run
147
+ config.backup = true
148
+ end
149
+ end
150
+
151
+ # @rbs evidence_path: String
152
+ # @rbs command: Array[String]
153
+ # @rbs return: void
154
+ def write_command(evidence_path, command)
155
+ path = "#{evidence_path}.command.json"
156
+ raise Error, "symlink command files are unsupported" if File.symlink?(path)
157
+
158
+ open_command(path, File::WRONLY | File::CREAT | File::TRUNC) do |file|
159
+ file.chmod(0o600)
160
+ file.write(JSON.generate(command))
161
+ end
162
+ end
163
+
164
+ # @rbs evidence_path: String
165
+ # @rbs return: Array[String]
166
+ def read_command(evidence_path)
167
+ path = "#{evidence_path}.command.json"
168
+ raise Error, "symlink command files are unsupported" if File.symlink?(path)
169
+ return [] unless File.file?(path)
170
+
171
+ value = open_command(path, File::RDONLY) { |file| JSON.parse(file.read) }
172
+ value.is_a?(Array) && value.all?(String) ? value : []
173
+ end
174
+
175
+ #: (String, Integer) { (File) -> untyped } -> untyped
176
+ def open_command(path, flags, &block)
177
+ flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
178
+ File.open(path, flags, 0o600, &block)
179
+ rescue Errno::ELOOP
180
+ raise Error, "symlink command files are unsupported"
181
+ end
182
+
183
+ end
184
+ end
185
+ end
@@ -5,14 +5,16 @@ require "logger"
5
5
 
6
6
  module Bullematic
7
7
  class Configuration
8
+ VALID_FIX_STRATEGIES = %i[includes preload eager_load].freeze #: Array[Symbol]
9
+
8
10
  # @rbs skip: to avoid empty array warning
9
11
  DEFAULTS = { #: Hash[Symbol, untyped]
10
12
  enabled: true,
11
- auto_fix: true,
13
+ auto_fix: false,
12
14
  target_paths: %w[app/controllers app/models app/services],
13
15
  skip_paths: [],
14
- dry_run: false,
15
- backup: false,
16
+ dry_run: true,
17
+ backup: true,
16
18
  fix_strategy: :includes,
17
19
  logger: nil,
18
20
  debug: false
@@ -39,7 +41,8 @@ module Bullematic
39
41
  # attr_accessor debug: bool
40
42
  # attr_writer logger: Logger?
41
43
  attr_accessor :enabled, :auto_fix, :target_paths, :skip_paths,
42
- :dry_run, :backup, :fix_strategy, :debug
44
+ :dry_run, :backup, :debug
45
+ attr_reader :fix_strategy
43
46
  attr_writer :logger
44
47
 
45
48
  #: () -> void
@@ -52,6 +55,14 @@ module Bullematic
52
55
  @logger ||= default_logger
53
56
  end
54
57
 
58
+ # @rbs strategy: Symbol
59
+ # @rbs return: Symbol
60
+ def fix_strategy=(strategy)
61
+ raise ArgumentError, "invalid fix strategy: #{strategy.inspect}" unless VALID_FIX_STRATEGIES.include?(strategy)
62
+
63
+ @fix_strategy = strategy
64
+ end
65
+
55
66
  private
56
67
 
57
68
  #: () -> Logger
@@ -1,6 +1,9 @@
1
1
  # rbs_inline: enabled
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "pathname"
5
+ require "digest"
6
+
4
7
  module Bullematic
5
8
  class Detection
6
9
  # @rbs @type: Symbol
@@ -10,6 +13,8 @@ module Bullematic
10
13
  # @rbs @source_file: String?
11
14
  # @rbs @line_number: Integer?
12
15
  # @rbs @method_name: String?
16
+ # @rbs @context_id: untyped
17
+ # @rbs @source_digest: String?
13
18
 
14
19
  # @rbs!
15
20
  # attr_reader type: Symbol
@@ -19,20 +24,60 @@ module Bullematic
19
24
  # attr_reader source_file: String?
20
25
  # attr_reader line_number: Integer?
21
26
  # attr_reader method_name: String?
27
+ # attr_reader context_id: untyped
28
+ # attr_reader source_digest: String?
22
29
  attr_reader :type, :base_class, :associations, :call_stack,
23
- :source_file, :line_number, :method_name
30
+ :source_file, :line_number, :method_name, :context_id, :source_digest
24
31
 
25
32
  # @rbs type: Symbol
26
33
  # @rbs base_class: untyped
27
34
  # @rbs associations: untyped
28
35
  # @rbs call_stack: Array[String]
36
+ # @rbs context_id: untyped
37
+ # @rbs source_digest: String?
29
38
  # @rbs return: void
30
- def initialize(type:, base_class:, associations:, call_stack:)
39
+ def initialize(type:, base_class:, associations:, call_stack:, context_id: nil, source_digest: nil)
31
40
  @type = type
32
41
  @base_class = base_class
33
42
  @associations = normalize_associations(associations)
34
43
  @call_stack = call_stack
44
+ @context_id = context_id
35
45
  parse_source_location
46
+ @source_digest = source_digest || current_source_digest
47
+ end
48
+
49
+ # @rbs data: Hash[String, untyped]
50
+ # @rbs return: Detection
51
+ def self.from_h(data)
52
+ type = data.fetch("type").to_s.to_sym
53
+ associations = data.fetch("associations")
54
+ call_stack = data.fetch("call_stack")
55
+ raise ArgumentError, "invalid evidence type" unless %i[n_plus_one unused_eager_loading].include?(type)
56
+ raise ArgumentError, "invalid evidence associations" unless associations.is_a?(Array)
57
+ raise ArgumentError, "invalid evidence call stack" unless call_stack.is_a?(Array)
58
+ source_digest = data["source_digest"]
59
+ raise ArgumentError, "invalid evidence source digest" unless source_digest.is_a?(String) && source_digest.match?(/\A[0-9a-f]{64}\z/)
60
+
61
+ new(
62
+ type: type,
63
+ base_class: data.fetch("base_class"),
64
+ associations: associations,
65
+ call_stack: call_stack,
66
+ context_id: data["context_id"],
67
+ source_digest: source_digest
68
+ )
69
+ end
70
+
71
+ # @rbs return: Hash[String, untyped]
72
+ def to_h
73
+ {
74
+ "type" => type.to_s,
75
+ "base_class" => model_class_name,
76
+ "associations" => associations.map(&:to_s),
77
+ "call_stack" => call_stack.map(&:to_s),
78
+ "context_id" => context_id,
79
+ "source_digest" => source_digest
80
+ }
36
81
  end
37
82
 
38
83
  #: () -> String
@@ -44,16 +89,25 @@ module Bullematic
44
89
  def fixable?
45
90
  !source_file.nil? &&
46
91
  !source_file.empty? &&
92
+ !associations.empty? &&
47
93
  target_path? &&
48
94
  !skip_path?
49
95
  end
50
96
 
97
+ #: () -> Array[untyped]
98
+ def fingerprint
99
+ [type, context_id, source_file, line_number, model_class_name, associations.sort]
100
+ end
101
+
51
102
  private
52
103
 
53
104
  # @rbs assocs: untyped
54
105
  # @rbs return: Array[Symbol]
55
106
  def normalize_associations(assocs)
56
- Array(assocs).map(&:to_sym)
107
+ values = Array(assocs) #: Array[untyped]
108
+ values.filter_map do |association|
109
+ association.to_sym if association.is_a?(String) || association.is_a?(Symbol)
110
+ end.uniq
57
111
  end
58
112
 
59
113
  #: () -> void
@@ -63,19 +117,19 @@ module Bullematic
63
117
 
64
118
  target_paths = config.target_paths
65
119
 
66
- @call_stack.each do |frame|
67
- next unless frame.is_a?(String)
120
+ @call_stack.each do |raw_frame|
121
+ frame = raw_frame.to_s
68
122
 
69
- match = frame.match(/\A(.+):(\d+)(?::in `(.+)')?/)
123
+ match = frame.match(/\A(.+):(\d+)(?::in [`'](.+)')?\z/)
70
124
  next unless match
71
125
 
72
126
  filepath = match[1]
73
127
  line = match[2].to_i
74
128
  method = match[3]
75
129
 
76
- next unless filepath && target_paths.any? { |path| filepath.include?(path) }
130
+ next unless filepath && path_in?(filepath, target_paths)
77
131
 
78
- @source_file = filepath
132
+ @source_file = absolute_path(filepath).to_s
79
133
  @line_number = line
80
134
  @method_name = method
81
135
  break
@@ -89,9 +143,7 @@ module Bullematic
89
143
  config = Bullematic.configuration
90
144
  return false unless config
91
145
 
92
- config.target_paths.any? do |path|
93
- source_file&.include?(path)
94
- end
146
+ path_in?(source_file, config.target_paths)
95
147
  end
96
148
 
97
149
  #: () -> bool
@@ -101,9 +153,54 @@ module Bullematic
101
153
  config = Bullematic.configuration
102
154
  return false unless config
103
155
 
104
- config.skip_paths.any? do |path|
105
- source_file&.include?(path)
156
+ path_in?(source_file, config.skip_paths)
157
+ end
158
+
159
+ # @rbs filepath: String
160
+ # @rbs paths: Array[String]
161
+ # @rbs return: bool
162
+ def path_in?(filepath, paths)
163
+ source = expand_path(filepath)
164
+
165
+ paths.any? do |path|
166
+ next false unless path.is_a?(String) && !path.empty?
167
+
168
+ target = expand_path(path)
169
+ source == target || source.to_s.start_with?("#{target}#{File::SEPARATOR}")
170
+ end
171
+ end
172
+
173
+ # @rbs path: String
174
+ # @rbs return: Pathname
175
+ def expand_path(path)
176
+ expanded = absolute_path(path)
177
+ return expanded unless expanded.exist?
178
+
179
+ begin
180
+ expanded.realpath
181
+ rescue Errno::ENOENT, Errno::EACCES
182
+ expanded
106
183
  end
107
184
  end
185
+
186
+ # @rbs path: String
187
+ # @rbs return: Pathname
188
+ def absolute_path(path)
189
+ pathname = Pathname.new(path)
190
+ return pathname if path.match?(%r{\A[A-Za-z]:[\\/]})
191
+ return pathname.expand_path if pathname.absolute?
192
+
193
+ root = defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root : Dir.pwd
194
+ Pathname.new(root).join(pathname).expand_path
195
+ end
196
+
197
+ # @rbs return: String?
198
+ def current_source_digest
199
+ return unless source_file && File.file?(source_file)
200
+
201
+ Digest::SHA256.hexdigest(File.binread(source_file))
202
+ rescue Errno::ENOENT, Errno::EACCES
203
+ nil
204
+ end
108
205
  end
109
206
  end
@@ -0,0 +1,78 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ module Bullematic
8
+ class EvidenceStore
9
+ ENV_KEY = "BULLEMATIC_RECORD_FILE"
10
+ DEFAULT_PATH = ".bullematic/evidence.jsonl"
11
+
12
+ class << self
13
+ #: () -> bool
14
+ def recording?
15
+ !ENV[ENV_KEY].to_s.empty?
16
+ end
17
+
18
+ #: () -> String
19
+ def path
20
+ File.expand_path(ENV.fetch(ENV_KEY, DEFAULT_PATH))
21
+ end
22
+
23
+ # @rbs detection: Detection
24
+ # @rbs filepath: String
25
+ # @rbs return: void
26
+ def append(detection, filepath = path)
27
+ FileUtils.mkdir_p(File.dirname(filepath))
28
+ reject_symlink!(filepath)
29
+ open_evidence(filepath, File::WRONLY | File::CREAT | File::APPEND) do |file|
30
+ file.flock(File::LOCK_EX)
31
+ file.puts(JSON.generate(detection.to_h))
32
+ end
33
+ end
34
+
35
+ # @rbs filepath: String
36
+ # @rbs return: void
37
+ def clear(filepath = path)
38
+ FileUtils.mkdir_p(File.dirname(filepath))
39
+ reject_symlink!(filepath)
40
+ open_evidence(filepath, File::WRONLY | File::CREAT | File::TRUNC) {}
41
+ end
42
+
43
+ # @rbs filepath: String
44
+ # @rbs return: Array[Detection]
45
+ def read(filepath = path)
46
+ reject_symlink!(filepath)
47
+ return [] unless File.file?(filepath)
48
+
49
+ detections = open_evidence(filepath, File::RDONLY) do |file|
50
+ file.each_line.filter_map do |line|
51
+ next if line.strip.empty?
52
+
53
+ Detection.from_h(JSON.parse(line))
54
+ end
55
+ end
56
+ detections.uniq(&:fingerprint)
57
+ rescue JSON::ParserError, KeyError, ArgumentError, TypeError => error
58
+ raise Error, "invalid evidence file #{filepath}: #{error.message}"
59
+ end
60
+
61
+ private
62
+
63
+ # @rbs filepath: String
64
+ # @rbs return: void
65
+ def reject_symlink!(filepath)
66
+ raise Error, "symlink evidence files are unsupported" if File.symlink?(filepath)
67
+ end
68
+
69
+ #: (String, Integer) { (File) -> untyped } -> untyped
70
+ def open_evidence(filepath, flags, &block)
71
+ flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
72
+ File.open(filepath, flags, 0o600, &block)
73
+ rescue Errno::ELOOP
74
+ raise Error, "symlink evidence files are unsupported"
75
+ end
76
+ end
77
+ end
78
+ end