crystalball 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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +19 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +18 -0
  5. data/.travis.yml +20 -0
  6. data/CODE_OF_CONDUCT.md +74 -0
  7. data/Gemfile +6 -0
  8. data/LICENSE +674 -0
  9. data/README.md +196 -0
  10. data/Rakefile +8 -0
  11. data/bin/console +15 -0
  12. data/bin/crystalball +5 -0
  13. data/bin/setup +8 -0
  14. data/crystalball.gemspec +48 -0
  15. data/lib/crystalball.rb +38 -0
  16. data/lib/crystalball/case_map.rb +19 -0
  17. data/lib/crystalball/execution_map.rb +55 -0
  18. data/lib/crystalball/git_repo.rb +58 -0
  19. data/lib/crystalball/map_generator.rb +81 -0
  20. data/lib/crystalball/map_generator/allocated_objects_strategy.rb +44 -0
  21. data/lib/crystalball/map_generator/allocated_objects_strategy/object_tracker.rb +44 -0
  22. data/lib/crystalball/map_generator/base_strategy.rb +21 -0
  23. data/lib/crystalball/map_generator/configuration.rb +54 -0
  24. data/lib/crystalball/map_generator/coverage_strategy.rb +34 -0
  25. data/lib/crystalball/map_generator/coverage_strategy/execution_detector.rb +23 -0
  26. data/lib/crystalball/map_generator/described_class_strategy.rb +35 -0
  27. data/lib/crystalball/map_generator/helpers/path_filter.rb +25 -0
  28. data/lib/crystalball/map_generator/object_sources_detector.rb +54 -0
  29. data/lib/crystalball/map_generator/object_sources_detector/definition_tracer.rb +39 -0
  30. data/lib/crystalball/map_generator/object_sources_detector/hierarchy_fetcher.rb +36 -0
  31. data/lib/crystalball/map_generator/strategies_collection.rb +43 -0
  32. data/lib/crystalball/map_storage/yaml_storage.rb +68 -0
  33. data/lib/crystalball/prediction.rb +34 -0
  34. data/lib/crystalball/predictor.rb +56 -0
  35. data/lib/crystalball/predictor/associated_specs.rb +40 -0
  36. data/lib/crystalball/predictor/modified_execution_paths.rb +21 -0
  37. data/lib/crystalball/predictor/modified_specs.rb +27 -0
  38. data/lib/crystalball/predictor_evaluator.rb +55 -0
  39. data/lib/crystalball/rails.rb +10 -0
  40. data/lib/crystalball/rails/map_generator/action_view_strategy.rb +46 -0
  41. data/lib/crystalball/rails/map_generator/action_view_strategy/patch.rb +42 -0
  42. data/lib/crystalball/rails/map_generator/i18n_strategy.rb +47 -0
  43. data/lib/crystalball/rails/map_generator/i18n_strategy/simple_patch.rb +88 -0
  44. data/lib/crystalball/rspec/prediction_builder.rb +43 -0
  45. data/lib/crystalball/rspec/runner.rb +95 -0
  46. data/lib/crystalball/rspec/runner/configuration.rb +70 -0
  47. data/lib/crystalball/simple_predictor.rb +18 -0
  48. data/lib/crystalball/source_diff.rb +37 -0
  49. data/lib/crystalball/source_diff/file_diff.rb +53 -0
  50. data/lib/crystalball/version.rb +5 -0
  51. metadata +263 -0
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_view'
4
+
5
+ module Crystalball
6
+ module Rails
7
+ class MapGenerator
8
+ class ActionViewStrategy
9
+ # Module to add new patched `compile!` method to ActionView::Template
10
+ module Patch
11
+ class << self
12
+ # Patches `ActionView::Template#compile!`. Renames original `compile!` to `old_compile!` and
13
+ # replaces it with custom one
14
+ def apply!
15
+ ::ActionView::Template.class_eval do
16
+ include Patch
17
+
18
+ alias_method :cb_original_compile!, :compile!
19
+ alias_method :compile!, :cb_patched_compile!
20
+ end
21
+ end
22
+
23
+ # Reverts original behavior of `ActionView::Template#compile!`
24
+ def revert!
25
+ ::ActionView::Template.class_eval do
26
+ alias_method :compile!, :cb_original_compile! # rubocop:disable Lint/DuplicateMethods
27
+ undef_method :cb_patched_compile!
28
+ end
29
+ end
30
+ end
31
+
32
+ # Will replace original `ActionView::Template#compile!`. Pushes path of a view to
33
+ # `ActionViewStrategy.views` and calls original `compile!`
34
+ def cb_patched_compile!(*args)
35
+ ActionViewStrategy.views.push identifier
36
+ cb_original_compile!(*args)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'crystalball/map_generator/base_strategy'
4
+ require 'crystalball/map_generator/helpers/path_filter'
5
+ require 'crystalball/rails/map_generator/i18n_strategy/simple_patch'
6
+
7
+ module Crystalball
8
+ module Rails
9
+ class MapGenerator
10
+ # Map generator strategy to build map of locale files used by an example.
11
+ class I18nStrategy
12
+ include ::Crystalball::MapGenerator::BaseStrategy
13
+ include ::Crystalball::MapGenerator::Helpers::PathFilter
14
+
15
+ class << self
16
+ # List of locale files affected by current example
17
+ #
18
+ # @return [Array<String>]
19
+ def locale_files
20
+ @locale_files ||= []
21
+ end
22
+
23
+ # Reset cached list of locale files
24
+ def reset_locale_files
25
+ @locale_files = []
26
+ end
27
+ end
28
+
29
+ def after_register
30
+ SimplePatch.apply!
31
+ end
32
+
33
+ def before_finalize
34
+ SimplePatch.revert!
35
+ end
36
+
37
+ # Adds to the case map the locale files used by the example
38
+ # @param [Crystalball::CaseMap] case_map - object holding example metadata and affected files
39
+ def call(case_map, _)
40
+ self.class.reset_locale_files
41
+ yield case_map
42
+ case_map.push(*filter(self.class.locale_files.compact))
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n'
4
+
5
+ module Crystalball
6
+ module Rails
7
+ class MapGenerator
8
+ class I18nStrategy
9
+ # Module to add new patched `load_file`, `store_translations` and `lookup`
10
+ # methods to `I18n::Backend::Simple`.
11
+ module SimplePatch
12
+ class << self
13
+ # Patches `I18n::Backend::Simple`.
14
+ def apply!
15
+ ::I18n::Backend::Simple.class_eval do
16
+ include SimplePatch
17
+
18
+ %i[load_file store_translations lookup].each do |method|
19
+ alias_method :"cb_original_#{method}", method
20
+ alias_method method, :"cb_patched_#{method}"
21
+ end
22
+ end
23
+ end
24
+
25
+ # Reverts original behavior of `I18n::Backend::Simple`
26
+ def revert!
27
+ ::I18n::Backend::Simple.class_eval do
28
+ %i[load_file store_translations lookup].each do |method|
29
+ alias_method method, :"cb_original_#{method}"
30
+ undef_method :"cb_patched_#{method}"
31
+ end
32
+ end
33
+ ::I18n.reload!
34
+ end
35
+ end
36
+
37
+ # Will replace original `I18n::Backend::Simple#load_file`.
38
+ # Stores filename in current thread
39
+ def cb_patched_load_file(filename, *args)
40
+ Thread.current[:cb_locale_file_name] = filename
41
+ cb_original_load_file(filename, *args)
42
+ end
43
+
44
+ # Will replace original `I18n::Backend::Simple#store_translations`.
45
+ # Adds filename for each value
46
+ def cb_patched_store_translations(locale, data, *args)
47
+ cb_add_filename_to_values(data, Thread.current[:cb_locale_file_name])
48
+ cb_original_store_translations(locale, data, *args)
49
+ end
50
+
51
+ # Will replace original `I18n::Backend::Simple#lookup`.
52
+ # Records origin filename of each value used.
53
+ def cb_patched_lookup(*args)
54
+ value = cb_original_lookup(*args)
55
+ cb_remove_and_track_filename_from_values(value)
56
+ end
57
+
58
+ private
59
+
60
+ def cb_add_filename_to_values(data, filename)
61
+ data.each do |key, value|
62
+ case value
63
+ when Hash
64
+ next if value.frozen?
65
+ cb_add_filename_to_values(value, filename)
66
+ else
67
+ data[key] = {cb_filename: filename, cb_value: value}.freeze
68
+ end
69
+ end
70
+ end
71
+
72
+ def cb_remove_and_track_filename_from_values(data)
73
+ return data unless data.is_a?(Hash)
74
+
75
+ if data.key?(:cb_filename)
76
+ ::Crystalball::Rails::MapGenerator::I18nStrategy.locale_files << data[:cb_filename]
77
+ return data[:cb_value]
78
+ end
79
+
80
+ data.each.with_object({}) do |(key, value), collector|
81
+ collector[key] = cb_remove_and_track_filename_from_values(value)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Crystalball
6
+ module RSpec
7
+ # Class for building a prediction for RSpec runner.
8
+ # Accepts configuration hash and builds a prediction according to configuration.
9
+ class PredictionBuilder
10
+ attr_reader :config
11
+
12
+ def initialize(config = {})
13
+ @config = config
14
+ end
15
+
16
+ def prediction
17
+ base_predictor.prediction
18
+ end
19
+
20
+ def expired_map?
21
+ return false if config['map_expiration_period'] <= 0
22
+
23
+ map_commit = repo.gcommit(map.commit) || raise("Cant find map commit info #{map.commit}")
24
+
25
+ map_commit.date < Time.now - config['map_expiration_period']
26
+ end
27
+
28
+ def map
29
+ @map ||= Crystalball::MapStorage::YAMLStorage.load(config['map_path'])
30
+ end
31
+
32
+ private
33
+
34
+ def repo
35
+ @repo ||= Crystalball::GitRepo.open(config['repo_path'])
36
+ end
37
+
38
+ def base_predictor
39
+ @base_predictor ||= config['predictor_class'].new(map, repo, from: config['diff_from'], to: config['diff_to'])
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core'
4
+ require 'crystalball/rspec/prediction_builder'
5
+
6
+ module Crystalball
7
+ module RSpec
8
+ # Our custom RSpec runner to run predictions
9
+ class Runner < ::RSpec::Core::Runner
10
+ class << self
11
+ def run(args, err = $stderr, out = $stdout)
12
+ return config['runner_class'].run(args, err, out) unless config['runner_class'] == self
13
+
14
+ out.puts "Crystalball starts to glow..."
15
+ super(args + build_prediction(out), err, out)
16
+ end
17
+
18
+ def reset!
19
+ self.prediction_builder = nil
20
+ self.config = nil
21
+ end
22
+
23
+ def prepare
24
+ config['runner_class'].load_map
25
+ end
26
+
27
+ def prediction_builder
28
+ @prediction_builder ||= PredictionBuilder.new(config)
29
+ end
30
+
31
+ def config
32
+ @config ||= begin
33
+ config_src = if config_file
34
+ require 'yaml'
35
+ YAML.safe_load(config_file.read)
36
+ else
37
+ {}
38
+ end
39
+
40
+ Configuration.new(config_src)
41
+ end
42
+ end
43
+
44
+ protected
45
+
46
+ def load_map
47
+ check_map($stdout) unless ENV['CRYSTALBALL_SKIP_MAP_CHECK']
48
+ prediction_builder.map
49
+ end
50
+
51
+ private
52
+
53
+ attr_writer :config, :prediction_builder
54
+
55
+ def config_file
56
+ file = Pathname.new(ENV.fetch('CRYSTALBALL_CONFIG', 'crystalball.yml'))
57
+ file = Pathname.new('config/crystalball.yml') unless file.exist?
58
+ file.exist? ? file : nil
59
+ end
60
+
61
+ def build_prediction(out)
62
+ check_map(out) unless ENV['CRYSTALBALL_SKIP_MAP_CHECK']
63
+ prediction = prediction_builder.prediction.compact
64
+ out.puts "Prediction: #{prediction.first(5).join(' ')}#{'...' if prediction.size > 5}"
65
+ out.puts "Starting RSpec."
66
+ prediction
67
+ end
68
+
69
+ def check_map(out)
70
+ out.puts 'Maps are outdated!' if prediction_builder.expired_map?
71
+ end
72
+ end
73
+
74
+ def run_specs(example_groups)
75
+ check_examples_limit(example_groups)
76
+ super
77
+ end
78
+
79
+ def check_examples_limit(example_groups)
80
+ limit = self.class.config['examples_limit'].to_i
81
+ return if ENV['CRYSTALBALL_SKIP_EXAMPLES_LIMIT'] || !limit.positive?
82
+
83
+ examples_count = @world.example_count(example_groups)
84
+
85
+ return if examples_count <= limit
86
+
87
+ @configuration.output_stream.puts "Example group size (#{examples_count}) is over the limit (#{limit})"
88
+ @configuration.output_stream.puts "Aborting spec run"
89
+ exit
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ require 'crystalball/rspec/runner/configuration'
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'crystalball/simple_predictor'
4
+
5
+ module Crystalball
6
+ module RSpec
7
+ class Runner
8
+ # Class for storing local runner configuration
9
+ class Configuration
10
+ def initialize(config = {})
11
+ @values = {
12
+ 'map_path' => 'tmp/execution_maps',
13
+ 'map_expiration_period' => 86_400,
14
+ 'repo_path' => Dir.pwd,
15
+ 'predictor_class_name' => 'Crystalball::SimplePredictor',
16
+ 'requires' => [],
17
+ 'diff_from' => 'HEAD',
18
+ 'diff_to' => nil,
19
+ 'runner_class_name' => 'Crystalball::RSpec::Runner'
20
+ }.merge(config)
21
+ end
22
+
23
+ def to_h
24
+ dynamic_values = {}
25
+ (private_methods - Object.private_instance_methods - %i[run_requires values]).each do |method|
26
+ dynamic_values[method.to_s] = send(method)
27
+ end
28
+
29
+ values.merge(dynamic_values)
30
+ end
31
+
32
+ def [](key)
33
+ respond_to?(key, true) ? send(key) : values[key]
34
+ end
35
+
36
+ private
37
+
38
+ def predictor_class
39
+ @predictor_class ||= begin
40
+ run_requires
41
+
42
+ Object.const_get(self['predictor_class_name'])
43
+ end
44
+ end
45
+
46
+ def runner_class
47
+ @runner_class ||= begin
48
+ run_requires
49
+
50
+ Object.const_get(self['runner_class_name'])
51
+ end
52
+ end
53
+
54
+ def map_path
55
+ @map_path ||= Pathname.new(values['map_path'])
56
+ end
57
+
58
+ def repo_path
59
+ @repo_path ||= Pathname.new(values['repo_path'])
60
+ end
61
+
62
+ attr_reader :values
63
+
64
+ def run_requires
65
+ self['requires'].each { |f| require f }
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'crystalball/predictor'
4
+ require 'crystalball/predictor/modified_execution_paths'
5
+ require 'crystalball/predictor/modified_specs'
6
+
7
+ module Crystalball
8
+ # Class to predict test failures with given execution map and sources diff
9
+ class SimplePredictor < Predictor
10
+ def initialize(*args, &block)
11
+ super(*args) do |p|
12
+ p.use Crystalball::Predictor::ModifiedExecutionPaths.new
13
+ p.use Crystalball::Predictor::ModifiedSpecs.new
14
+ block&.call(p)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'crystalball/source_diff/file_diff'
4
+
5
+ module Crystalball
6
+ # Wrapper class representing Git source diff for given repo
7
+ class SourceDiff
8
+ include Enumerable
9
+ extend Forwardable
10
+
11
+ delegate %i[stats size lines from to] => :git_diff
12
+
13
+ # @param [Git::Diff] git_diff raw diff made by ruby-git gem
14
+ def initialize(git_diff)
15
+ @git_diff = git_diff
16
+ end
17
+
18
+ # Iterates over each changed file of diff
19
+ #
20
+ def each
21
+ changeset.each { |file| yield file }
22
+ end
23
+
24
+ def empty?
25
+ changeset.none?
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :git_diff
31
+
32
+ # TODO: Include untracked to changeset
33
+ def changeset
34
+ @changeset ||= git_diff.map { |file_diff| FileDiff.new(file_diff) }
35
+ end
36
+ end
37
+ end