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.
- checksums.yaml +7 -0
- data/.gitignore +19 -0
- data/.rspec +3 -0
- data/.rubocop.yml +18 -0
- data/.travis.yml +20 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +6 -0
- data/LICENSE +674 -0
- data/README.md +196 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/crystalball +5 -0
- data/bin/setup +8 -0
- data/crystalball.gemspec +48 -0
- data/lib/crystalball.rb +38 -0
- data/lib/crystalball/case_map.rb +19 -0
- data/lib/crystalball/execution_map.rb +55 -0
- data/lib/crystalball/git_repo.rb +58 -0
- data/lib/crystalball/map_generator.rb +81 -0
- data/lib/crystalball/map_generator/allocated_objects_strategy.rb +44 -0
- data/lib/crystalball/map_generator/allocated_objects_strategy/object_tracker.rb +44 -0
- data/lib/crystalball/map_generator/base_strategy.rb +21 -0
- data/lib/crystalball/map_generator/configuration.rb +54 -0
- data/lib/crystalball/map_generator/coverage_strategy.rb +34 -0
- data/lib/crystalball/map_generator/coverage_strategy/execution_detector.rb +23 -0
- data/lib/crystalball/map_generator/described_class_strategy.rb +35 -0
- data/lib/crystalball/map_generator/helpers/path_filter.rb +25 -0
- data/lib/crystalball/map_generator/object_sources_detector.rb +54 -0
- data/lib/crystalball/map_generator/object_sources_detector/definition_tracer.rb +39 -0
- data/lib/crystalball/map_generator/object_sources_detector/hierarchy_fetcher.rb +36 -0
- data/lib/crystalball/map_generator/strategies_collection.rb +43 -0
- data/lib/crystalball/map_storage/yaml_storage.rb +68 -0
- data/lib/crystalball/prediction.rb +34 -0
- data/lib/crystalball/predictor.rb +56 -0
- data/lib/crystalball/predictor/associated_specs.rb +40 -0
- data/lib/crystalball/predictor/modified_execution_paths.rb +21 -0
- data/lib/crystalball/predictor/modified_specs.rb +27 -0
- data/lib/crystalball/predictor_evaluator.rb +55 -0
- data/lib/crystalball/rails.rb +10 -0
- data/lib/crystalball/rails/map_generator/action_view_strategy.rb +46 -0
- data/lib/crystalball/rails/map_generator/action_view_strategy/patch.rb +42 -0
- data/lib/crystalball/rails/map_generator/i18n_strategy.rb +47 -0
- data/lib/crystalball/rails/map_generator/i18n_strategy/simple_patch.rb +88 -0
- data/lib/crystalball/rspec/prediction_builder.rb +43 -0
- data/lib/crystalball/rspec/runner.rb +95 -0
- data/lib/crystalball/rspec/runner/configuration.rb +70 -0
- data/lib/crystalball/simple_predictor.rb +18 -0
- data/lib/crystalball/source_diff.rb +37 -0
- data/lib/crystalball/source_diff/file_diff.rb +53 -0
- data/lib/crystalball/version.rb +5 -0
- 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
|