crystalball 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|