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,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
|