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,81 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Crystalball
|
4
|
+
# Class to generate execution map during RSpec build execution
|
5
|
+
class MapGenerator
|
6
|
+
extend Forwardable
|
7
|
+
|
8
|
+
attr_reader :configuration
|
9
|
+
delegate %i[map_storage strategies dump_threshold map_class] => :configuration
|
10
|
+
|
11
|
+
class << self
|
12
|
+
# Registers Crystalball handlers to generate execution map during specs execution
|
13
|
+
#
|
14
|
+
# @param [Proc] block to configure MapGenerator and Register strategies
|
15
|
+
def start!(&block)
|
16
|
+
generator = new(&block)
|
17
|
+
|
18
|
+
::RSpec.configure do |c|
|
19
|
+
c.before(:suite) { generator.start! }
|
20
|
+
|
21
|
+
c.around(:each) { |e| generator.refresh_for_case(e) }
|
22
|
+
|
23
|
+
c.after(:suite) { generator.finalize! }
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def initialize
|
29
|
+
@configuration = Configuration.new
|
30
|
+
@configuration.commit = repo.object('HEAD').sha if repo
|
31
|
+
yield @configuration if block_given?
|
32
|
+
end
|
33
|
+
|
34
|
+
# Registers strategies and prepares metadata for execution map
|
35
|
+
def start!
|
36
|
+
raise 'Repository is not pristine! Please stash all your changes' if repo && !repo.pristine?
|
37
|
+
|
38
|
+
self.map = nil
|
39
|
+
map_storage.clear!
|
40
|
+
map_storage.dump(map.metadata.to_h)
|
41
|
+
|
42
|
+
strategies.reverse.each(&:after_start)
|
43
|
+
self.started = true
|
44
|
+
end
|
45
|
+
|
46
|
+
# Runs example and collects execution map for it
|
47
|
+
def refresh_for_case(example)
|
48
|
+
map << strategies.run(CaseMap.new(example), example) { example.run }
|
49
|
+
check_dump_threshold
|
50
|
+
end
|
51
|
+
|
52
|
+
# Finalizes strategies and saves map
|
53
|
+
def finalize!
|
54
|
+
return unless started
|
55
|
+
|
56
|
+
strategies.each(&:before_finalize)
|
57
|
+
map_storage.dump(map.cases) if map.size.positive?
|
58
|
+
end
|
59
|
+
|
60
|
+
def map
|
61
|
+
@map ||= map_class.new(metadata: {commit: configuration.commit, version: configuration.version})
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
attr_writer :map
|
67
|
+
attr_accessor :started
|
68
|
+
|
69
|
+
def repo
|
70
|
+
@repo = GitRepo.open('.') unless defined?(@repo)
|
71
|
+
@repo
|
72
|
+
end
|
73
|
+
|
74
|
+
def check_dump_threshold
|
75
|
+
return unless dump_threshold.positive? && map.size >= dump_threshold
|
76
|
+
|
77
|
+
map_storage.dump(map.cases)
|
78
|
+
map.clear!
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'crystalball/map_generator/base_strategy'
|
4
|
+
require 'crystalball/map_generator/object_sources_detector'
|
5
|
+
require 'crystalball/map_generator/allocated_objects_strategy/object_tracker'
|
6
|
+
|
7
|
+
module Crystalball
|
8
|
+
class MapGenerator
|
9
|
+
# Map generator strategy to get paths to files contains definition for all objects and its
|
10
|
+
# ancestors allocated during test example.
|
11
|
+
class AllocatedObjectsStrategy
|
12
|
+
include BaseStrategy
|
13
|
+
extend Forwardable
|
14
|
+
|
15
|
+
attr_reader :execution_detector, :object_tracker
|
16
|
+
|
17
|
+
delegate %i[after_register before_finalize] => :execution_detector
|
18
|
+
|
19
|
+
def self.build(only: [], root: Dir.pwd)
|
20
|
+
hierarchy_fetcher = ObjectSourcesDetector::HierarchyFetcher.new(only)
|
21
|
+
execution_detector = ObjectSourcesDetector.new(root_path: root, hierarchy_fetcher: hierarchy_fetcher)
|
22
|
+
|
23
|
+
new(execution_detector: execution_detector, object_tracker: ObjectTracker.new(only_of: only))
|
24
|
+
end
|
25
|
+
|
26
|
+
# @param [#detect] execution_detector
|
27
|
+
# @param [#created_during] object_tracker
|
28
|
+
def initialize(execution_detector:, object_tracker:)
|
29
|
+
@object_tracker = object_tracker
|
30
|
+
@execution_detector = execution_detector
|
31
|
+
end
|
32
|
+
|
33
|
+
# Adds to the affected files every file which contain the definition of the
|
34
|
+
# classes of the objects allocated during the spec execution.
|
35
|
+
# @param [Crystalball::CaseMap] case_map - object holding example metadata and affected files
|
36
|
+
def call(case_map, example)
|
37
|
+
classes = object_tracker.used_classes_during do
|
38
|
+
yield case_map, example
|
39
|
+
end
|
40
|
+
case_map.push(*execution_detector.detect(classes))
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'set'
|
4
|
+
|
5
|
+
module Crystalball
|
6
|
+
class MapGenerator
|
7
|
+
class AllocatedObjectsStrategy
|
8
|
+
# Class to list object classes used during a block
|
9
|
+
class ObjectTracker
|
10
|
+
attr_reader :only_of
|
11
|
+
|
12
|
+
# @param [Array<Module>] only_of - classes or modules to watch on
|
13
|
+
def initialize(only_of: ['Object'])
|
14
|
+
@only_of = only_of
|
15
|
+
@created_object_classes = Set.new
|
16
|
+
end
|
17
|
+
|
18
|
+
# @yield a block to execute
|
19
|
+
# @return [Array<Object>] classes of objects allocated during the block execution
|
20
|
+
def used_classes_during(&block)
|
21
|
+
self.created_object_classes = Set.new
|
22
|
+
trace_point.enable(&block)
|
23
|
+
created_object_classes
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
attr_accessor :created_object_classes
|
29
|
+
|
30
|
+
def whitelisted_constants
|
31
|
+
@whitelisted_constants ||= only_of.map { |str| Object.const_get(str) }
|
32
|
+
end
|
33
|
+
|
34
|
+
def trace_point
|
35
|
+
@trace_point ||= TracePoint.new(:c_call) do |tp|
|
36
|
+
next unless tp.method_id == :new || tp.method_id == :allocate
|
37
|
+
next unless whitelisted_constants.any? { |c| tp.self <= c }
|
38
|
+
created_object_classes << tp.self
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Crystalball
|
4
|
+
class MapGenerator
|
5
|
+
# Map generator strategy interface
|
6
|
+
module BaseStrategy
|
7
|
+
def after_register; end
|
8
|
+
|
9
|
+
def after_start; end
|
10
|
+
|
11
|
+
def before_finalize; end
|
12
|
+
|
13
|
+
# Each strategy must implement #call augmenting the affected_files list and
|
14
|
+
# yielding back the CaseMap.
|
15
|
+
# @param [Crystalball::CaseMap] _case_map - object holding example metadata and affected files
|
16
|
+
def call(_case_map, _example)
|
17
|
+
raise NotImplementedError
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'crystalball/map_generator/strategies_collection'
|
4
|
+
|
5
|
+
module Crystalball
|
6
|
+
class MapGenerator
|
7
|
+
# Configuration of map generator. Is can be accessed as a first argument inside
|
8
|
+
# `Crystalball::MapGenerator.start! { |config| config } block.
|
9
|
+
class Configuration
|
10
|
+
attr_writer :map_storage
|
11
|
+
attr_writer :map_class
|
12
|
+
attr_accessor :commit
|
13
|
+
attr_accessor :version
|
14
|
+
|
15
|
+
attr_reader :strategies
|
16
|
+
|
17
|
+
def initialize
|
18
|
+
@strategies = StrategiesCollection.new
|
19
|
+
end
|
20
|
+
|
21
|
+
def map_class
|
22
|
+
@map_class ||= ExecutionMap
|
23
|
+
end
|
24
|
+
|
25
|
+
def map_storage_path
|
26
|
+
@map_storage_path ||= Pathname('execution_map.yml')
|
27
|
+
end
|
28
|
+
|
29
|
+
def map_storage_path=(value)
|
30
|
+
@map_storage_path = Pathname(value)
|
31
|
+
end
|
32
|
+
|
33
|
+
def map_storage
|
34
|
+
@map_storage ||= MapStorage::YAMLStorage.new(map_storage_path)
|
35
|
+
end
|
36
|
+
|
37
|
+
def dump_threshold
|
38
|
+
@dump_threshold ||= 100
|
39
|
+
end
|
40
|
+
|
41
|
+
def dump_threshold=(value)
|
42
|
+
@dump_threshold = value.to_i
|
43
|
+
end
|
44
|
+
|
45
|
+
# Register new strategy for map generation
|
46
|
+
#
|
47
|
+
# @param [Crystalball::MapGenerator::BaseStrategy] strategy
|
48
|
+
def register(strategy)
|
49
|
+
@strategies.push strategy
|
50
|
+
strategy.after_register
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'coverage'
|
4
|
+
require 'crystalball/map_generator/base_strategy'
|
5
|
+
require 'crystalball/map_generator/coverage_strategy/execution_detector'
|
6
|
+
|
7
|
+
module Crystalball
|
8
|
+
class MapGenerator
|
9
|
+
# Map generator strategy based on harvesting Coverage information during example execution
|
10
|
+
class CoverageStrategy
|
11
|
+
include BaseStrategy
|
12
|
+
|
13
|
+
attr_reader :execution_detector
|
14
|
+
|
15
|
+
def initialize(execution_detector = ExecutionDetector.new)
|
16
|
+
@execution_detector = execution_detector
|
17
|
+
end
|
18
|
+
|
19
|
+
def after_register
|
20
|
+
Coverage.start
|
21
|
+
end
|
22
|
+
|
23
|
+
# Adds to the case_map's affected files the ones the ones in which
|
24
|
+
# the coverage has changed after the tests runs.
|
25
|
+
# @param [Crystalball::CaseMap] case_map - object holding example metadata and affected files
|
26
|
+
def call(case_map, _)
|
27
|
+
before = Coverage.peek_result
|
28
|
+
yield case_map
|
29
|
+
after = Coverage.peek_result
|
30
|
+
case_map.push(*execution_detector.detect(before, after))
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../helpers/path_filter'
|
4
|
+
|
5
|
+
module Crystalball
|
6
|
+
class MapGenerator
|
7
|
+
class CoverageStrategy
|
8
|
+
# Class for detecting code execution path based on coverage information diff
|
9
|
+
class ExecutionDetector
|
10
|
+
include ::Crystalball::MapGenerator::Helpers::PathFilter
|
11
|
+
# Detects files affected during example execution. Transforms absolute paths to relative.
|
12
|
+
# Exclude paths outside of repository
|
13
|
+
#
|
14
|
+
# @param[Array<String>] list of files affected before example execution
|
15
|
+
# @param[Array<String>] list of files affected after example execution
|
16
|
+
# @return [Array<String>]
|
17
|
+
def detect(before, after)
|
18
|
+
filter after.reject { |file_name, after_coverage| before[file_name] == after_coverage }.keys
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'crystalball/map_generator/base_strategy'
|
4
|
+
require 'crystalball/map_generator/object_sources_detector'
|
5
|
+
|
6
|
+
module Crystalball
|
7
|
+
class MapGenerator
|
8
|
+
# Map generator strategy to get paths to files contains definition of described_class and its
|
9
|
+
# ancestors.
|
10
|
+
class DescribedClassStrategy
|
11
|
+
include BaseStrategy
|
12
|
+
extend Forwardable
|
13
|
+
|
14
|
+
attr_reader :execution_detector
|
15
|
+
|
16
|
+
delegate %i[after_register before_finalize] => :execution_detector
|
17
|
+
|
18
|
+
# @param [#detect] execution_detector - object that, given a list of objects,
|
19
|
+
# returns the paths where the classes or modules of the list are defined
|
20
|
+
def initialize(execution_detector: ObjectSourcesDetector.new(root_path: Dir.pwd))
|
21
|
+
@execution_detector = execution_detector
|
22
|
+
end
|
23
|
+
|
24
|
+
# @param [Crystalball::CaseMap] case_map - object holding example metadata and affected files
|
25
|
+
# @param [RSpec::Core::Example] example
|
26
|
+
def call(case_map, example)
|
27
|
+
yield case_map
|
28
|
+
|
29
|
+
described_class = example.metadata[:described_class]
|
30
|
+
|
31
|
+
case_map.push(*execution_detector.detect([described_class])) if described_class
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Crystalball
|
4
|
+
class MapGenerator
|
5
|
+
module Helpers
|
6
|
+
# Helper module to filter file paths
|
7
|
+
module PathFilter
|
8
|
+
attr_reader :root_path
|
9
|
+
|
10
|
+
# @param [String] root_path - absolute path to root folder of repository
|
11
|
+
def initialize(root_path = Dir.pwd)
|
12
|
+
@root_path = root_path
|
13
|
+
end
|
14
|
+
|
15
|
+
# @param [Array<String>] paths
|
16
|
+
# @return relatve paths inside root_path only
|
17
|
+
def filter(paths)
|
18
|
+
paths
|
19
|
+
.select { |file_name| file_name.start_with?(root_path) }
|
20
|
+
.map { |file_name| file_name.sub("#{root_path}/", '') }
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'crystalball/map_generator/helpers/path_filter'
|
4
|
+
require 'crystalball/map_generator/object_sources_detector/hierarchy_fetcher'
|
5
|
+
require 'crystalball/map_generator/object_sources_detector/definition_tracer'
|
6
|
+
|
7
|
+
module Crystalball
|
8
|
+
class MapGenerator
|
9
|
+
# Class for files paths affected object definition
|
10
|
+
class ObjectSourcesDetector
|
11
|
+
include ::Crystalball::MapGenerator::Helpers::PathFilter
|
12
|
+
|
13
|
+
attr_reader :definition_tracer, :hierarchy_fetcher
|
14
|
+
|
15
|
+
def initialize(
|
16
|
+
root_path:,
|
17
|
+
definition_tracer: DefinitionTracer.new(root_path),
|
18
|
+
hierarchy_fetcher: HierarchyFetcher.new
|
19
|
+
)
|
20
|
+
super(root_path)
|
21
|
+
|
22
|
+
@definition_tracer = definition_tracer
|
23
|
+
@hierarchy_fetcher = hierarchy_fetcher
|
24
|
+
end
|
25
|
+
|
26
|
+
def after_register
|
27
|
+
definition_tracer.start
|
28
|
+
end
|
29
|
+
|
30
|
+
def before_finalize
|
31
|
+
definition_tracer.stop
|
32
|
+
end
|
33
|
+
|
34
|
+
# Detects files affected during example execution. Transforms absolute paths to relative.
|
35
|
+
# Exclude paths outside of repository
|
36
|
+
#
|
37
|
+
# @param[Array<String>] list of files affected before example execution
|
38
|
+
# @return [Array<String>]
|
39
|
+
def detect(objects)
|
40
|
+
modules = objects.map do |object|
|
41
|
+
object.is_a?(Module) ? object : object.class
|
42
|
+
end.uniq
|
43
|
+
|
44
|
+
paths = modules.flat_map do |mod|
|
45
|
+
hierarchy_fetcher.ancestors_for(mod).flat_map do |ancestor|
|
46
|
+
definition_tracer.constants_definition_paths[ancestor]
|
47
|
+
end
|
48
|
+
end.compact.uniq
|
49
|
+
|
50
|
+
filter paths
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Crystalball
|
4
|
+
class MapGenerator
|
5
|
+
class ObjectSourcesDetector
|
6
|
+
# Class to save paths to classes and modules definitions during code loading. Should be
|
7
|
+
# started as soon as possible. Use #constants_definition_paths to fetch traced info
|
8
|
+
class DefinitionTracer
|
9
|
+
attr_reader :trace_point, :constants_definition_paths, :root_path
|
10
|
+
|
11
|
+
def initialize(root_path)
|
12
|
+
@root_path = root_path
|
13
|
+
@constants_definition_paths = {}
|
14
|
+
end
|
15
|
+
|
16
|
+
def start
|
17
|
+
self.trace_point ||= TracePoint.new(:class) do |tp|
|
18
|
+
mod = tp.self
|
19
|
+
path = tp.path
|
20
|
+
|
21
|
+
next unless path&.start_with?(root_path)
|
22
|
+
|
23
|
+
constants_definition_paths[mod] ||= []
|
24
|
+
constants_definition_paths[mod] << path
|
25
|
+
end.tap(&:enable)
|
26
|
+
end
|
27
|
+
|
28
|
+
def stop
|
29
|
+
trace_point&.disable
|
30
|
+
self.trace_point = nil
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
attr_writer :trace_point, :constants_definition_paths
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|