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,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ class MapGenerator
5
+ class ObjectSourcesDetector
6
+ # Class to get full hierarchy of a module(including singleton_class)
7
+ class HierarchyFetcher
8
+ attr_reader :stop_modules
9
+
10
+ # @param [Array<String>] stop_modules - list of classes or modules which
11
+ # will be used to stop hierarchy lookup
12
+ def initialize(stop_modules = [])
13
+ @stop_modules = stop_modules
14
+ end
15
+
16
+ # @param [Module] mod - the module for which to fetch the ancestors
17
+ # @return [Array<Module>] list of ancestors of a module
18
+ def ancestors_for(mod)
19
+ (pick_ancestors(mod) + pick_ancestors(mod.singleton_class)).uniq
20
+ end
21
+
22
+ private
23
+
24
+ def stop_consts
25
+ @stop_consts ||= stop_modules.map { |str| Object.const_get(str) }
26
+ end
27
+
28
+ def pick_ancestors(mod)
29
+ ancestors = mod.ancestors
30
+ index = ancestors.index { |k| stop_consts.include?(k) } || ancestors.size
31
+ ancestors[0...index]
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ class MapGenerator
5
+ # Manages map generation strategies
6
+ class StrategiesCollection
7
+ include Enumerable
8
+
9
+ def initialize(strategies = [])
10
+ @strategies = strategies
11
+ end
12
+
13
+ # Calls every strategy on the given case map and returns the modified case map
14
+ # @param [Crystalball::CaseMap] case_map - initial case map
15
+ # @return [Crystalball::CaseMap] case map augmented by each strategy
16
+ def run(case_map, example, &block)
17
+ run_for_strategies(case_map, example, *_strategies.reverse, &block)
18
+ case_map
19
+ end
20
+
21
+ def method_missing(method_name, *args, &block)
22
+ _strategies.public_send(method_name, *args, &block) || super
23
+ end
24
+
25
+ def respond_to_missing?(method_name, *_args)
26
+ _strategies.respond_to?(method_name, false) || super
27
+ end
28
+
29
+ private
30
+
31
+ def _strategies
32
+ @strategies
33
+ end
34
+
35
+ def run_for_strategies(case_map, example, *strats, &block)
36
+ return yield(case_map) if strats.empty?
37
+
38
+ strat = strats.shift
39
+ strat.call(case_map, example) { |c| run_for_strategies(c, example, *strats, &block) }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Crystalball
6
+ class MapStorage
7
+ # Exception class for missing map files
8
+ class NoFilesFoundError < StandardError; end
9
+
10
+ # YAML persistence adapter for execution map storage
11
+ class YAMLStorage
12
+ attr_reader :path
13
+
14
+ class << self
15
+ # Loads map from given path
16
+ #
17
+ # @param [String] path to map
18
+ # @return [Crystalball::ExecutionMap]
19
+ def load(path)
20
+ meta, cases = *read_files(path).transpose
21
+
22
+ guard_metadata_consistency(meta)
23
+
24
+ Object.const_get(meta.first[:type]).new(metadata: meta.first, cases: cases.inject(&:merge!))
25
+ end
26
+
27
+ private
28
+
29
+ def read_files(path)
30
+ paths = path.directory? ? path.each_child.select(&:file?) : [path]
31
+
32
+ raise NoFilesFoundError unless paths.any?(&:exist?)
33
+
34
+ paths.map do |file|
35
+ metadata, *cases = file.read.split("---\n").reject(&:empty?).map do |yaml|
36
+ YAML.safe_load(yaml, [Symbol])
37
+ end
38
+ cases = cases.inject(&:merge!)
39
+
40
+ [metadata, cases]
41
+ end
42
+ end
43
+
44
+ def guard_metadata_consistency(metadata)
45
+ uniq = metadata.uniq
46
+ raise "Can't load execution maps with different metadata. Metadata: #{uniq}" if uniq.size > 1
47
+ end
48
+ end
49
+
50
+ # @param [String] path to store execution map
51
+ def initialize(path)
52
+ @path = path
53
+ end
54
+
55
+ # Removes storage file
56
+ def clear!
57
+ path.delete if path.exist?
58
+ end
59
+
60
+ # Writes data to storage file
61
+ #
62
+ # @param [Hash] data to write to storage file
63
+ def dump(data)
64
+ path.open('a') { |f| f.write YAML.dump(data) }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ # Class for Crystalball prediction results
5
+ class Prediction
6
+ def initialize(cases)
7
+ @cases = cases
8
+ end
9
+
10
+ def compact
11
+ result = []
12
+ sort_by(&:length).each do |c|
13
+ result << c unless result.any? { |r| c.start_with?(r, "./#{r}") }
14
+ end
15
+ result
16
+ end
17
+
18
+ def to_a
19
+ cases
20
+ end
21
+
22
+ def method_missing(*args, &block)
23
+ cases.respond_to?(*args) ? cases.public_send(*args, &block) : super
24
+ end
25
+
26
+ def respond_to_missing?(*args)
27
+ cases.respond_to?(*args)
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :cases
33
+ end
34
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ # Class to predict test failures with given execution map and sources diff
5
+ class Predictor
6
+ attr_reader :map, :from, :to, :prediction_strategies
7
+
8
+ # @param [Crystalball::ExecutionMap] map execution map
9
+ # @param [Crystalball::GitRepo] repo to build execution list on
10
+ # @param [String] from starting commit for diff. Default: HEAD
11
+ # @param [String] to ending commit for diff. Default: nil
12
+ def initialize(map, repo, from: 'HEAD', to: nil)
13
+ @map = map
14
+ @repo = repo
15
+ @from = from
16
+ @to = to
17
+ @prediction_strategies = []
18
+ yield self if block_given?
19
+ end
20
+
21
+ # Adds additional predictor to use
22
+ #
23
+ # @param [#call] strategy - the strategy can be any object that responds to #call
24
+ def use(strategy)
25
+ prediction_strategies << strategy
26
+ end
27
+
28
+ # @return [Crystalball::Prediction] list of examples which may fail
29
+ def prediction
30
+ Prediction.new(filter(raw_prediction(diff)))
31
+ end
32
+ alias cases prediction
33
+
34
+ def diff
35
+ repo.diff(from, to)
36
+ end
37
+
38
+ private
39
+
40
+ # TODO: check if it would be better to call predictors with one case instead of passing the whole map.
41
+ def predict!(current_diff)
42
+ prediction_strategies.flat_map { |strategy| strategy.call(current_diff, map) }
43
+ end
44
+ alias raw_prediction predict!
45
+
46
+ attr_reader :repo
47
+
48
+ def filter(raw_cases)
49
+ raw_cases.compact.select { |example| example_to_file_path(example).exist? }.uniq
50
+ end
51
+
52
+ def example_to_file_path(example)
53
+ repo.repo_path.join(example.split('[').first).expand_path
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ class Predictor
5
+ # Used with `predictor.use Crystalball::Predictor::AssociatedSpecs.new(from: %r{models/(.*).rb}, to: "./spec/models/%s_spec.rb")`.
6
+ # When used will look for files matched to `from` regex and use captures to fill `to` string to
7
+ # get paths of proper specs
8
+ class AssociatedSpecs
9
+ # @param [Regexp] from - regular expression to match specific files and get proper captures
10
+ # @param [String] to - string in sprintf format to get proper files using captures of regexp
11
+ def initialize(from:, to:)
12
+ @from = from
13
+ @to = to
14
+ end
15
+
16
+ # This strategy does not depend on a previously generated case map.
17
+ # It uses the defined regex rules to infer which specs to run.
18
+ # @param [Crystalball::SourceDiff] diff - the diff from which to predict
19
+ # which specs should run
20
+ # @return [Array<String>] the spec paths associated with the changes
21
+ def call(diff, _)
22
+ diff.map(&:relative_path).grep(from)
23
+ .map { |source_file_path| to % captures(source_file_path) }
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :from, :to
29
+
30
+ def captures(file_path)
31
+ match = file_path.match(from)
32
+ if match.names.any?
33
+ match.names.map(&:to_sym).zip(match.captures).to_h
34
+ else
35
+ match.captures
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ class Predictor
5
+ # Used with `predictor.use Crystalball::Predictor::ModifiedExecutionPaths.new`. When used will check the map which
6
+ # specs depend on which files and will return only those specs which depend on files modified since last time map
7
+ # was generated.
8
+ class ModifiedExecutionPaths
9
+ # @param [Crystalball::SourceDiff] diff - the diff from which to predict
10
+ # which specs should run
11
+ # @param [Crystalball::CaseMap] map - the map with the relations of
12
+ # examples and affected files
13
+ # @return [Array<String>] the spec paths associated with the changes
14
+ def call(diff, map)
15
+ map.cases.map do |uid, case_map|
16
+ uid if diff.any? { |file| case_map.include?(file.relative_path) }
17
+ end.compact
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ class Predictor
5
+ # Used with `predictor.use Crystalball::Predictor::ModifiedSpecs.new`. Will find files that match spec regexp and
6
+ # return all new or modified files. You can specify spec regexp using first parameter to `#initialize`.
7
+ class ModifiedSpecs
8
+ # @param [Regexp] spec_pattern - regexp to filter specs files
9
+ def initialize(spec_pattern = %r{spec/.*_spec\.rb\z})
10
+ @spec_pattern = spec_pattern
11
+ end
12
+
13
+ # This strategy does not depend on a previously generated case map.
14
+ # It uses the spec pattern to determine which specs should run.
15
+ # @param [Crystalball::SourceDiff] diff - the diff from which to predict
16
+ # which specs should run
17
+ # @return [Array<String>] the spec paths associated with the changes
18
+ def call(diff, _)
19
+ diff.reject(&:deleted?).map(&:new_relative_path).grep(spec_pattern)
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :spec_pattern
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ # Class to collect statistics about prediction quality
5
+ class PredictorEvaluator
6
+ attr_reader :predictor, :actual_failures
7
+
8
+ # @param [Crystalball::Predictor] predictor - configured predictor to fetch list of examples which might fail
9
+ # @param [Array<String>] actual_failures - list of actual failed examples
10
+ def initialize(predictor, actual_failures:)
11
+ @predictor = predictor
12
+ @actual_failures = actual_failures
13
+ end
14
+
15
+ def predicted_failures
16
+ @predicted_failures ||= actual_failures.select do |failure|
17
+ prediction.any? { |p| failure.include?(p) }
18
+ end
19
+ end
20
+
21
+ def unpredicted_failures
22
+ actual_failures - predicted_failures
23
+ end
24
+
25
+ def diff_size
26
+ predictor.diff.lines
27
+ end
28
+
29
+ def prediction_to_diff_ratio
30
+ prediction_size.to_f / diff_size
31
+ end
32
+
33
+ def prediction_scale
34
+ prediction_size.to_f / map_size
35
+ end
36
+
37
+ def prediction_rate
38
+ actual_failures.empty? ? 1.0 : predicted_failures.size.to_f / actual_failures.size
39
+ end
40
+
41
+ def prediction_size
42
+ @prediction_size ||= predictor.map.cases.keys.select { |example| prediction.any? { |p| example.include?(p) } }.size
43
+ end
44
+
45
+ def map_size
46
+ predictor.map.size
47
+ end
48
+
49
+ private
50
+
51
+ def prediction
52
+ @prediction ||= predictor.prediction
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'crystalball/rails/map_generator/action_view_strategy'
4
+ require 'crystalball/rails/map_generator/i18n_strategy'
5
+
6
+ module Crystalball
7
+ # Module containting Rails-specific stuff for Crystalball
8
+ module Rails
9
+ end
10
+ end
@@ -0,0 +1,46 @@
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/action_view_strategy/patch'
6
+
7
+ module Crystalball
8
+ module Rails
9
+ class MapGenerator
10
+ # Map generator strategy to build map of views affected by an example.
11
+ # It patches `ActionView::Template#compile!` to get original name of compiled views.
12
+ class ActionViewStrategy
13
+ include ::Crystalball::MapGenerator::BaseStrategy
14
+ include ::Crystalball::MapGenerator::Helpers::PathFilter
15
+
16
+ class << self
17
+ # List of views affected by current example
18
+ #
19
+ # @return [Array<String>]
20
+ attr_reader :views
21
+
22
+ # Reset cached list of views
23
+ def reset_views
24
+ @views = []
25
+ end
26
+ end
27
+
28
+ def after_start
29
+ Patch.apply!
30
+ end
31
+
32
+ def before_finalize
33
+ Patch.revert!
34
+ end
35
+
36
+ # Adds views related to the spec to the case map
37
+ # @param [Crystalball::CaseMap] case_map - object holding example metadata and affected files
38
+ def call(case_map, _)
39
+ self.class.reset_views
40
+ yield case_map
41
+ case_map.push(*filter(self.class.views))
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end