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