crystalball 0.5.0 → 0.6.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +4 -1
  3. data/.rubocop.yml +4 -0
  4. data/.travis.yml +1 -1
  5. data/CHANGELOG.md +18 -0
  6. data/LICENSE +22 -674
  7. data/README.md +13 -158
  8. data/crystalball.gemspec +6 -2
  9. data/docs/img/favicon.ico +0 -0
  10. data/docs/img/logo.png +0 -0
  11. data/docs/index.md +44 -0
  12. data/docs/map_generators.md +149 -0
  13. data/docs/predictors.md +75 -0
  14. data/docs/runner.md +24 -0
  15. data/lib/crystalball.rb +8 -3
  16. data/lib/crystalball/active_record.rb +4 -0
  17. data/lib/crystalball/example_group_map.rb +19 -0
  18. data/lib/crystalball/execution_map.rb +17 -16
  19. data/lib/crystalball/extensions/git.rb +4 -0
  20. data/lib/crystalball/extensions/git/base.rb +14 -0
  21. data/lib/crystalball/extensions/git/lib.rb +18 -0
  22. data/lib/crystalball/factory_bot.rb +3 -0
  23. data/lib/crystalball/git_repo.rb +2 -7
  24. data/lib/crystalball/logging.rb +51 -0
  25. data/lib/crystalball/map_generator.rb +5 -7
  26. data/lib/crystalball/map_generator/allocated_objects_strategy.rb +6 -5
  27. data/lib/crystalball/map_generator/allocated_objects_strategy/object_tracker.rb +1 -0
  28. data/lib/crystalball/map_generator/base_strategy.rb +5 -4
  29. data/lib/crystalball/map_generator/configuration.rb +1 -1
  30. data/lib/crystalball/map_generator/coverage_strategy.rb +6 -5
  31. data/lib/crystalball/map_generator/described_class_strategy.rb +5 -5
  32. data/lib/crystalball/map_generator/factory_bot_strategy.rb +59 -0
  33. data/lib/crystalball/map_generator/factory_bot_strategy/dsl_patch.rb +40 -0
  34. data/lib/crystalball/map_generator/factory_bot_strategy/dsl_patch/factory_path_fetcher.rb +30 -0
  35. data/lib/crystalball/map_generator/factory_bot_strategy/factory_gem_loader.rb +27 -0
  36. data/lib/crystalball/map_generator/factory_bot_strategy/factory_runner_patch.rb +25 -0
  37. data/lib/crystalball/map_generator/parser_strategy.rb +60 -0
  38. data/lib/crystalball/map_generator/parser_strategy/processor.rb +129 -0
  39. data/lib/crystalball/map_generator/strategies_collection.rb +9 -9
  40. data/lib/crystalball/map_storage/yaml_storage.rb +7 -6
  41. data/lib/crystalball/prediction.rb +12 -11
  42. data/lib/crystalball/predictor.rb +7 -5
  43. data/lib/crystalball/predictor/associated_specs.rb +9 -4
  44. data/lib/crystalball/predictor/helpers/affected_example_groups_detector.rb +20 -0
  45. data/lib/crystalball/predictor/helpers/path_formatter.rb +18 -0
  46. data/lib/crystalball/predictor/modified_execution_paths.rb +11 -5
  47. data/lib/crystalball/predictor/modified_specs.rb +8 -2
  48. data/lib/crystalball/predictor/modified_support_specs.rb +39 -0
  49. data/lib/crystalball/predictor/strategy.rb +16 -0
  50. data/lib/crystalball/predictor_evaluator.rb +1 -1
  51. data/lib/crystalball/rails.rb +1 -0
  52. data/lib/crystalball/rails/helpers/base_schema_parser.rb +51 -0
  53. data/lib/crystalball/rails/helpers/schema_definition_parser.rb +36 -0
  54. data/lib/crystalball/rails/helpers/schema_definition_parser/active_record.rb +21 -0
  55. data/lib/crystalball/rails/helpers/schema_definition_parser/table_content_parser.rb +27 -0
  56. data/lib/crystalball/rails/map_generator/action_view_strategy.rb +5 -5
  57. data/lib/crystalball/rails/map_generator/action_view_strategy/patch.rb +1 -1
  58. data/lib/crystalball/rails/map_generator/i18n_strategy.rb +5 -5
  59. data/lib/crystalball/rails/map_generator/i18n_strategy/simple_patch.rb +1 -0
  60. data/lib/crystalball/rails/predictor/modified_schema.rb +81 -0
  61. data/lib/crystalball/rails/tables_map.rb +53 -0
  62. data/lib/crystalball/rails/tables_map_generator.rb +84 -0
  63. data/lib/crystalball/rails/tables_map_generator/configuration.rb +39 -0
  64. data/lib/crystalball/rspec/filtering.rb +52 -0
  65. data/lib/crystalball/rspec/prediction_builder.rb +21 -11
  66. data/lib/crystalball/rspec/prediction_pruning.rb +56 -0
  67. data/lib/crystalball/rspec/prediction_pruning/examples_pruner.rb +70 -0
  68. data/lib/crystalball/rspec/runner.rb +39 -27
  69. data/lib/crystalball/rspec/runner/configuration.rb +24 -14
  70. data/lib/crystalball/rspec/standard_prediction_builder.rb +17 -0
  71. data/lib/crystalball/source_diff.rb +12 -2
  72. data/lib/crystalball/source_diff/file_diff.rb +1 -1
  73. data/lib/crystalball/source_diff/formatting_checker.rb +50 -0
  74. data/lib/crystalball/version.rb +1 -1
  75. data/mkdocs.yml +23 -0
  76. metadata +102 -7
  77. data/lib/crystalball/case_map.rb +0 -19
  78. data/lib/crystalball/simple_predictor.rb +0 -18
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ module Rails
5
+ # Storage for tables map
6
+ class TablesMap
7
+ extend Forwardable
8
+
9
+ # Simple data object for map metadata information
10
+ class Metadata
11
+ attr_reader :commit, :version
12
+
13
+ # @param [String] commit - SHA of commit
14
+ # @param [Numeric] version - map generator version number
15
+ def initialize(commit: nil, version: nil, **_)
16
+ @commit = commit
17
+ @version = version
18
+ end
19
+
20
+ def to_h
21
+ {type: TablesMap.name, commit: commit, version: version}
22
+ end
23
+ end
24
+
25
+ attr_reader :example_groups, :metadata
26
+
27
+ delegate %i[commit version] => :metadata
28
+ delegate %i[size [] []=] => :example_groups
29
+
30
+ # @param [Hash] metadata - add or override metadata of execution map
31
+ # @param [Hash] example_groups - initial list of tables
32
+ def initialize(metadata: {}, example_groups: {})
33
+ @metadata = Metadata.new(**metadata)
34
+ @example_groups = example_groups
35
+ end
36
+
37
+ # Remove all example_groups
38
+ def clear!
39
+ self.example_groups = {}
40
+ end
41
+
42
+ def add(files:, for_table:)
43
+ example_groups[for_table] ||= []
44
+ example_groups[for_table] += files
45
+ example_groups[for_table].uniq!
46
+ end
47
+
48
+ private
49
+
50
+ attr_writer :example_groups, :metadata
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'crystalball/rails/tables_map'
4
+ require 'crystalball/rails/tables_map_generator/configuration'
5
+
6
+ module Crystalball
7
+ module Rails
8
+ # Class to generate tables to files map during RSpec build execution
9
+ class TablesMapGenerator
10
+ extend Forwardable
11
+
12
+ attr_reader :configuration
13
+ delegate %i[map_storage object_sources_detector] => :configuration
14
+
15
+ class << self
16
+ # Registers Crystalball handlers to generate execution map during specs execution
17
+ #
18
+ # @param [Proc] block to configure MapGenerator and Register strategies
19
+ def start!(&block)
20
+ generator = new(&block)
21
+
22
+ ::RSpec.configure do |c|
23
+ c.before(:suite) { generator.start! }
24
+ c.after(:suite) { generator.finalize! }
25
+ end
26
+ end
27
+ end
28
+
29
+ def initialize
30
+ @configuration = Configuration.new
31
+ @configuration.commit = repo.object('HEAD').sha if repo
32
+ yield @configuration if block_given?
33
+ object_sources_detector.after_register
34
+ end
35
+
36
+ # Prepares metadata for execution map
37
+ def start!
38
+ self.map = nil
39
+ map_storage.clear!
40
+
41
+ map_storage.dump(map.metadata.to_h)
42
+
43
+ self.started = true
44
+ end
45
+
46
+ # Finalizes and saves map
47
+ def finalize!
48
+ return unless started
49
+
50
+ collect_tables_info
51
+
52
+ object_sources_detector.before_finalize
53
+ map_storage.dump(map.example_groups) if map.size.positive?
54
+ end
55
+
56
+ # @return [Crystalball::Rails::TablesMap]
57
+ def map
58
+ @map ||= TablesMap.new(metadata: {commit: configuration.commit, version: configuration.version})
59
+ end
60
+
61
+ private
62
+
63
+ def repo
64
+ @repo = GitRepo.open('.') unless defined?(@repo)
65
+ @repo
66
+ end
67
+
68
+ def collect_tables_info
69
+ ActiveRecord::Base.descendants.each do |descendant|
70
+ table_name = descendant.table_name
71
+
72
+ next if table_name.nil?
73
+
74
+ files = object_sources_detector.detect([descendant])
75
+
76
+ map.add(files: files, for_table: table_name)
77
+ end
78
+ end
79
+
80
+ attr_writer :map
81
+ attr_accessor :started
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'crystalball/map_generator/object_sources_detector'
4
+
5
+ module Crystalball
6
+ module Rails
7
+ class TablesMapGenerator
8
+ # Configuration of tables map generator. Is can be accessed as a first argument inside
9
+ # `Crystalball::Rails::TablesMapGenerator.start! { |config| config } block.
10
+ class Configuration
11
+ attr_writer :map_storage
12
+ attr_accessor :commit
13
+ attr_accessor :version
14
+ attr_writer :root_path
15
+ attr_writer :object_sources_detector
16
+
17
+ def map_storage_path
18
+ @map_storage_path ||= Pathname('tables_map.yml')
19
+ end
20
+
21
+ def map_storage_path=(value)
22
+ @map_storage_path = Pathname(value)
23
+ end
24
+
25
+ def map_storage
26
+ @map_storage ||= MapStorage::YAMLStorage.new(map_storage_path)
27
+ end
28
+
29
+ def root_path
30
+ @root_path ||= Dir.pwd
31
+ end
32
+
33
+ def object_sources_detector
34
+ @object_sources_detector ||= ::Crystalball::MapGenerator::ObjectSourcesDetector.new(root_path: root_path)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ module RSpec
5
+ # This class is meant to remove the example filtering options
6
+ # for example_groups when a prediction contains a file path and the same file
7
+ # example id.
8
+ #
9
+ # For example, if a prediction contains `./spec/foo_spec.rb[1:1] ./spec/foo_spec.rb`,
10
+ # only `./spec/foo_spec.rb[1:1]` would run, because of the way RSpec
11
+ # filters are designed.
12
+ #
13
+ # Therefore, we need to manually remove the filters from such example_groups.
14
+ class Filtering
15
+ # @param [RSpec::Core::Configuration] config
16
+ # @param [Array<String>] paths
17
+ def self.remove_unnecessary_filters(config, paths)
18
+ new(config).remove_unnecessary_filters(paths)
19
+ end
20
+
21
+ def initialize(configuration)
22
+ @configuration = configuration
23
+ end
24
+
25
+ def remove_unnecessary_filters(files_or_directories)
26
+ directories, files = files_or_directories.partition { |f| File.directory?(f) }
27
+ remove_unecessary_filters_from_files(files)
28
+ remove_unecessary_filters_from_directories(directories)
29
+ end
30
+
31
+ def remove_unecessary_filters_from_directories(directories)
32
+ directories.each do |dir|
33
+ files = configuration.__send__(:gather_directories, dir)
34
+ remove_unecessary_filters_from_files(files)
35
+ end
36
+ end
37
+
38
+ def remove_unecessary_filters_from_files(files)
39
+ files.select { |f| ::RSpec::Core::Example.parse_id(f).last.nil? }.each do |file|
40
+ next remove_unecessary_filters(fd) if File.directory?(file)
41
+
42
+ path = ::RSpec::Core::Metadata.relative_path(File.expand_path(file))
43
+ configuration.filter_manager.inclusions[:ids]&.delete(path)
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :configuration
50
+ end
51
+ end
52
+ end
@@ -14,29 +14,39 @@ module Crystalball
14
14
  end
15
15
 
16
16
  def prediction
17
- base_predictor.prediction
17
+ predictor.prediction
18
18
  end
19
19
 
20
20
  def expired_map?
21
- return false if config['map_expiration_period'] <= 0
21
+ expiration_period = config['map_expiration_period'].to_i
22
+ return false unless expiration_period.positive?
22
23
 
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']
24
+ execution_map.timestamp.to_i <= Time.now.to_i - config['map_expiration_period']
26
25
  end
27
26
 
28
- def map
29
- @map ||= Crystalball::MapStorage::YAMLStorage.load(config['map_path'])
27
+ def execution_map
28
+ @execution_map ||= Crystalball::MapStorage::YAMLStorage.load(config['execution_map_path'])
30
29
  end
31
30
 
32
- private
33
-
34
31
  def repo
35
32
  @repo ||= Crystalball::GitRepo.open(config['repo_path'])
36
33
  end
37
34
 
38
- def base_predictor
39
- @base_predictor ||= config['predictor_class'].new(map, repo, from: config['diff_from'], to: config['diff_to'])
35
+ private
36
+
37
+ # This method should be overridden in ancestor. Example:
38
+ #
39
+ # def predictor
40
+ # super do |p|
41
+ # p.use Crystalball::Predictor::ModifiedExecutionPaths.new
42
+ # p.use Crystalball::Predictor::ModifiedSpecs.new
43
+ # end
44
+ # end
45
+ #
46
+ def predictor(&block)
47
+ raise NotImplementedError, 'Configure `prediction_builder_class_name` in `crystalball.yml` and override `predictor` method' unless block_given?
48
+
49
+ @predictor ||= Crystalball::Predictor.new(execution_map, repo, from: config['diff_from'], to: config['diff_to'], &block)
40
50
  end
41
51
  end
42
52
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'crystalball/rspec/prediction_pruning/examples_pruner'
4
+
5
+ module Crystalball
6
+ module RSpec
7
+ # Module contains logic related to examples_limit configuration option for our runner.
8
+ module PredictionPruning
9
+ def self.included(base)
10
+ base.extend ClassMethods
11
+ end
12
+
13
+ # Class methods for prediction pruning logic
14
+ module ClassMethods
15
+ def examples_limit
16
+ config['examples_limit'].to_i
17
+ end
18
+
19
+ private
20
+
21
+ def prune_prediction_to_limit(prediction)
22
+ return prediction if !examples_limit.positive? || prediction.size <= examples_limit
23
+
24
+ Crystalball.log :warn, "Prediction size #{prediction.size} is over the limit (#{examples_limit})"
25
+ Crystalball.log :warn, "Prediction is pruned to fit the limit!"
26
+
27
+ # Actual examples size is not less than prediction size.
28
+ prediction.first(examples_limit)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def examples_limit
35
+ self.class.examples_limit
36
+ end
37
+
38
+ def reconfiguration_needed?
39
+ examples_limit.positive? && @world.example_count > examples_limit
40
+ end
41
+
42
+ def reconfigure_to_limit
43
+ pruner = ExamplesPruner.new(@world, to: examples_limit)
44
+
45
+ @options = ::RSpec::Core::ConfigurationOptions.new(pruner.pruned_set)
46
+ @world.reset
47
+ @world.filtered_examples.clear
48
+ @world.instance_variable_get(:@example_group_counts_by_spec_file).clear
49
+ @configuration.reset
50
+ @configuration.reset_filters
51
+
52
+ @options.configure(@configuration)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ module RSpec
5
+ module PredictionPruning
6
+ # A class to prune given world example groups to fit the limit.
7
+ class ExamplesPruner
8
+ # Simple data object for holding context ids array with total examples size
9
+ class ContextIdsSet
10
+ attr_reader :ids, :size
11
+ alias to_a ids
12
+
13
+ def initialize
14
+ @size = 0
15
+ @ids = []
16
+ end
17
+
18
+ def add(id, size = 1)
19
+ @size += size
20
+ @ids << id
21
+ end
22
+ end
23
+
24
+ attr_reader :world, :limit
25
+
26
+ # @param [RSpec::Core::World] rspec_world RSpec world instance
27
+ # @param [Integer] to upper bound limit for prediction.
28
+ def initialize(rspec_world, to:)
29
+ @world = rspec_world
30
+ @limit = to
31
+ end
32
+
33
+ # @return [Array<String>] set of example and context ids to run
34
+ def pruned_set
35
+ resulting_set = ContextIdsSet.new
36
+ world.ordered_example_groups.each { |g| prune_to_limit(g, resulting_set) }
37
+ resulting_set.to_a
38
+ end
39
+
40
+ private
41
+
42
+ def prune_to_limit(group, resulting_set)
43
+ return if resulting_set.size >= limit
44
+
45
+ group_size = world.example_count([group])
46
+
47
+ if resulting_set.size + group_size > limit
48
+ (group.descendants - [group]).each do |g|
49
+ prune_to_limit(g, resulting_set)
50
+ end
51
+
52
+ add_examples(group, resulting_set)
53
+ else
54
+ resulting_set.add(group.id, group_size)
55
+ end
56
+ end
57
+
58
+ def add_examples(group, resulting_set)
59
+ limit_diff = limit - resulting_set.size
60
+
61
+ return unless limit_diff.positive?
62
+
63
+ group.filtered_examples.first(limit_diff).each do |example|
64
+ resulting_set.add(example.id)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -2,17 +2,26 @@
2
2
 
3
3
  require 'rspec/core'
4
4
  require 'crystalball/rspec/prediction_builder'
5
+ require 'crystalball/rspec/filtering'
6
+ require 'crystalball/rspec/prediction_pruning'
5
7
 
6
8
  module Crystalball
7
9
  module RSpec
8
10
  # Our custom RSpec runner to run predictions
9
11
  class Runner < ::RSpec::Core::Runner
12
+ include PredictionPruning
13
+
10
14
  class << self
11
15
  def run(args, err = $stderr, out = $stdout)
12
16
  return config['runner_class'].run(args, err, out) unless config['runner_class'] == self
13
17
 
14
- out.puts "Crystalball starts to glow..."
15
- super(args + build_prediction(out), err, out)
18
+ Crystalball.log :info, "Crystalball starts to glow..."
19
+ prediction = build_prediction
20
+
21
+ Crystalball.log :debug, "Prediction: #{prediction.first(5).join(' ')}#{'...' if prediction.size > 5}"
22
+ Crystalball.log :info, "Starting RSpec."
23
+
24
+ super(args + prediction, err, out)
16
25
  end
17
26
 
18
27
  def reset!
@@ -21,11 +30,11 @@ module Crystalball
21
30
  end
22
31
 
23
32
  def prepare
24
- config['runner_class'].load_map
33
+ config['runner_class'].load_execution_map
25
34
  end
26
35
 
27
36
  def prediction_builder
28
- @prediction_builder ||= PredictionBuilder.new(config)
37
+ @prediction_builder ||= config['prediction_builder_class'].new(config)
29
38
  end
30
39
 
31
40
  def config
@@ -43,9 +52,9 @@ module Crystalball
43
52
 
44
53
  protected
45
54
 
46
- def load_map
47
- check_map($stdout) unless ENV['CRYSTALBALL_SKIP_MAP_CHECK']
48
- prediction_builder.map
55
+ def load_execution_map
56
+ check_map
57
+ prediction_builder.execution_map
49
58
  end
50
59
 
51
60
  private
@@ -58,35 +67,38 @@ module Crystalball
58
67
  file.exist? ? file : nil
59
68
  end
60
69
 
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
70
+ def build_prediction
71
+ check_map
72
+ prune_prediction_to_limit(prediction_builder.prediction.sort_by(&:length))
67
73
  end
68
74
 
69
- def check_map(out)
70
- out.puts 'Maps are outdated!' if prediction_builder.expired_map?
75
+ def check_map
76
+ Crystalball.log :warn, 'Maps are outdated!' if prediction_builder.expired_map?
71
77
  end
72
78
  end
73
79
 
74
- def run_specs(example_groups)
75
- check_examples_limit(example_groups)
76
- super
77
- end
80
+ def setup(err, out)
81
+ configure(err, out)
82
+ @configuration.load_spec_files
78
83
 
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?
84
+ Filtering.remove_unnecessary_filters(@configuration, @options.options[:files_or_directories_to_run])
82
85
 
83
- examples_count = @world.example_count(example_groups)
86
+ if reconfiguration_needed?
87
+ Crystalball.log :warn, "Prediction examples size #{@world.example_count} is over the limit (#{examples_limit})"
88
+ Crystalball.log :warn, "Prediction is pruned to fit the limit!"
84
89
 
85
- return if examples_count <= limit
90
+ reconfigure_to_limit
91
+ @configuration.load_spec_files
92
+ end
93
+
94
+ @world.announce_filters
95
+ end
86
96
 
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
97
+ # Backward compatibility for RSpec < 3.7
98
+ def configure(err, out)
99
+ @configuration.error_stream = err
100
+ @configuration.output_stream = out if @configuration.output_stream == $stdout
101
+ @options.configure(@configuration)
90
102
  end
91
103
  end
92
104
  end