crystalball 0.5.0 → 0.6.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +4 -1
  3. data/.rubocop.yml +4 -0
  4. data/.travis.yml +1 -1
  5. data/CHANGELOG.md +18 -0
  6. data/LICENSE +22 -674
  7. data/README.md +13 -158
  8. data/crystalball.gemspec +6 -2
  9. data/docs/img/favicon.ico +0 -0
  10. data/docs/img/logo.png +0 -0
  11. data/docs/index.md +44 -0
  12. data/docs/map_generators.md +149 -0
  13. data/docs/predictors.md +75 -0
  14. data/docs/runner.md +24 -0
  15. data/lib/crystalball.rb +8 -3
  16. data/lib/crystalball/active_record.rb +4 -0
  17. data/lib/crystalball/example_group_map.rb +19 -0
  18. data/lib/crystalball/execution_map.rb +17 -16
  19. data/lib/crystalball/extensions/git.rb +4 -0
  20. data/lib/crystalball/extensions/git/base.rb +14 -0
  21. data/lib/crystalball/extensions/git/lib.rb +18 -0
  22. data/lib/crystalball/factory_bot.rb +3 -0
  23. data/lib/crystalball/git_repo.rb +2 -7
  24. data/lib/crystalball/logging.rb +51 -0
  25. data/lib/crystalball/map_generator.rb +5 -7
  26. data/lib/crystalball/map_generator/allocated_objects_strategy.rb +6 -5
  27. data/lib/crystalball/map_generator/allocated_objects_strategy/object_tracker.rb +1 -0
  28. data/lib/crystalball/map_generator/base_strategy.rb +5 -4
  29. data/lib/crystalball/map_generator/configuration.rb +1 -1
  30. data/lib/crystalball/map_generator/coverage_strategy.rb +6 -5
  31. data/lib/crystalball/map_generator/described_class_strategy.rb +5 -5
  32. data/lib/crystalball/map_generator/factory_bot_strategy.rb +59 -0
  33. data/lib/crystalball/map_generator/factory_bot_strategy/dsl_patch.rb +40 -0
  34. data/lib/crystalball/map_generator/factory_bot_strategy/dsl_patch/factory_path_fetcher.rb +30 -0
  35. data/lib/crystalball/map_generator/factory_bot_strategy/factory_gem_loader.rb +27 -0
  36. data/lib/crystalball/map_generator/factory_bot_strategy/factory_runner_patch.rb +25 -0
  37. data/lib/crystalball/map_generator/parser_strategy.rb +60 -0
  38. data/lib/crystalball/map_generator/parser_strategy/processor.rb +129 -0
  39. data/lib/crystalball/map_generator/strategies_collection.rb +9 -9
  40. data/lib/crystalball/map_storage/yaml_storage.rb +7 -6
  41. data/lib/crystalball/prediction.rb +12 -11
  42. data/lib/crystalball/predictor.rb +7 -5
  43. data/lib/crystalball/predictor/associated_specs.rb +9 -4
  44. data/lib/crystalball/predictor/helpers/affected_example_groups_detector.rb +20 -0
  45. data/lib/crystalball/predictor/helpers/path_formatter.rb +18 -0
  46. data/lib/crystalball/predictor/modified_execution_paths.rb +11 -5
  47. data/lib/crystalball/predictor/modified_specs.rb +8 -2
  48. data/lib/crystalball/predictor/modified_support_specs.rb +39 -0
  49. data/lib/crystalball/predictor/strategy.rb +16 -0
  50. data/lib/crystalball/predictor_evaluator.rb +1 -1
  51. data/lib/crystalball/rails.rb +1 -0
  52. data/lib/crystalball/rails/helpers/base_schema_parser.rb +51 -0
  53. data/lib/crystalball/rails/helpers/schema_definition_parser.rb +36 -0
  54. data/lib/crystalball/rails/helpers/schema_definition_parser/active_record.rb +21 -0
  55. data/lib/crystalball/rails/helpers/schema_definition_parser/table_content_parser.rb +27 -0
  56. data/lib/crystalball/rails/map_generator/action_view_strategy.rb +5 -5
  57. data/lib/crystalball/rails/map_generator/action_view_strategy/patch.rb +1 -1
  58. data/lib/crystalball/rails/map_generator/i18n_strategy.rb +5 -5
  59. data/lib/crystalball/rails/map_generator/i18n_strategy/simple_patch.rb +1 -0
  60. data/lib/crystalball/rails/predictor/modified_schema.rb +81 -0
  61. data/lib/crystalball/rails/tables_map.rb +53 -0
  62. data/lib/crystalball/rails/tables_map_generator.rb +84 -0
  63. data/lib/crystalball/rails/tables_map_generator/configuration.rb +39 -0
  64. data/lib/crystalball/rspec/filtering.rb +52 -0
  65. data/lib/crystalball/rspec/prediction_builder.rb +21 -11
  66. data/lib/crystalball/rspec/prediction_pruning.rb +56 -0
  67. data/lib/crystalball/rspec/prediction_pruning/examples_pruner.rb +70 -0
  68. data/lib/crystalball/rspec/runner.rb +39 -27
  69. data/lib/crystalball/rspec/runner/configuration.rb +24 -14
  70. data/lib/crystalball/rspec/standard_prediction_builder.rb +17 -0
  71. data/lib/crystalball/source_diff.rb +12 -2
  72. data/lib/crystalball/source_diff/file_diff.rb +1 -1
  73. data/lib/crystalball/source_diff/formatting_checker.rb +50 -0
  74. data/lib/crystalball/version.rb +1 -1
  75. data/mkdocs.yml +23 -0
  76. metadata +102 -7
  77. data/lib/crystalball/case_map.rb +0 -19
  78. data/lib/crystalball/simple_predictor.rb +0 -18
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'crystalball/map_generator/factory_bot_strategy/dsl_patch/factory_path_fetcher'
4
+
5
+ module Crystalball
6
+ class MapGenerator
7
+ class FactoryBotStrategy
8
+ # Module to add new `factory` method to FactoryBot::Syntax::Default::DSL and FactoryBot::Syntax::Default::ModifyDSL
9
+ module DSLPatch
10
+ class << self
11
+ # Patches `FactoryBot::Syntax::Default::DSL#factory` and `FactoryBot::Syntax::Default::ModifyDSL#factory`.
12
+ def apply!
13
+ classes_to_patch.each { |klass| klass.prepend DSLPatch }
14
+ end
15
+
16
+ private
17
+
18
+ def classes_to_patch
19
+ [
20
+ FactoryBotStrategy.factory_bot_constant::Syntax::Default::DSL,
21
+ FactoryBotStrategy.factory_bot_constant::Syntax::Default::ModifyDSL
22
+ ]
23
+ end
24
+ end
25
+
26
+ # Overrides `FactoryBot::Syntax::Default::DSL#factory` and `FactoryBot::Syntax::Default::ModifyDSL#factory`.
27
+ # Pushes path of a factory to `FactoryBotStrategy.factory_definitions` and calls original `factory`
28
+ def factory(*args, &block)
29
+ factory_path = FactoryPathFetcher.fetch
30
+ name = args.first.to_s
31
+
32
+ FactoryBotStrategy.factory_definitions[name] ||= []
33
+ FactoryBotStrategy.factory_definitions[name] << factory_path
34
+
35
+ super
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ class MapGenerator
5
+ class FactoryBotStrategy
6
+ module DSLPatch
7
+ # This module is used to fetch file path with factory definition from callstack
8
+ module FactoryPathFetcher
9
+ # Fetches file path with factory definition from callstack
10
+ #
11
+ # @return [String]
12
+ def self.fetch
13
+ factories_definition_paths = FactoryBotStrategy
14
+ .factory_bot_constant
15
+ .definition_file_paths
16
+ .map { |path| Pathname(path).expand_path.to_s }
17
+
18
+ factory_definition_call = caller.find do |method_call|
19
+ factories_definition_paths.any? do |path|
20
+ method_call.start_with?(path)
21
+ end
22
+ end
23
+
24
+ factory_definition_call.split(':').first
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ class MapGenerator
5
+ class FactoryBotStrategy
6
+ # A helper module to load `factory_bot` or `factory_girl`
7
+ module FactoryGemLoader
8
+ class << self
9
+ NAMES = %w[factory_bot factory_girl].freeze
10
+
11
+ # Tries to require `factory_bot` first. Requires `factory_girl` if `factory_bot` is not available
12
+ # Raises `LoadError` if both of them are not available.
13
+ def require!
14
+ NAMES.any? do |factory_gem_name|
15
+ begin
16
+ require factory_gem_name
17
+ true
18
+ rescue LoadError
19
+ false
20
+ end
21
+ end || (raise LoadError, "Can't load `factory_bot` or `factory_girl`")
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ class MapGenerator
5
+ class FactoryBotStrategy
6
+ # Module to add new `run` method to FactoryBot::FactoryRunner
7
+ module FactoryRunnerPatch
8
+ class << self
9
+ # Patches `FactoryBot::FactoryRunner#run`.
10
+ def apply!
11
+ FactoryBotStrategy.factory_bot_constant::FactoryRunner.prepend FactoryRunnerPatch
12
+ end
13
+ end
14
+
15
+ # Overrides `FactoryBot::FactoryRunner#run`. Pushes factory name to
16
+ # `FactoryBotStrategy.used_factories` and calls original `run`
17
+ def run(*)
18
+ factory = FactoryBotStrategy.factory_bot_constant.factory_by_name(@name)
19
+ FactoryBotStrategy.used_factories << factory.name.to_s
20
+ super
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'crystalball/map_generator/parser_strategy/processor'
4
+ require 'crystalball/map_generator/helpers/path_filter'
5
+
6
+ module Crystalball
7
+ class MapGenerator
8
+ # Map generator strategy based on parsing source files to detect constant definition
9
+ # and tracing method calls on those constants.
10
+ class ParserStrategy
11
+ include BaseStrategy
12
+ include Helpers::PathFilter
13
+
14
+ attr_reader :const_definition_paths
15
+
16
+ def initialize(root = Dir.pwd, pattern:)
17
+ @root_path = Pathname.new(root).realpath.to_s
18
+ @processor = Processor.new
19
+ @const_definition_paths = {}
20
+ @pattern = pattern
21
+ end
22
+
23
+ def after_register
24
+ files_to_inspect.each do |path|
25
+ processor.consts_defined_in(path).each do |const|
26
+ (const_definition_paths[const] ||= []) << path
27
+ end
28
+ end
29
+ end
30
+
31
+ # Parses the current example group map seeking calls to class methods and adds
32
+ # the classes to the map.
33
+ # @param [Crystalball::ExampleGroupMap] example_map - object holding example metadata and used files
34
+ # @param [RSpec::Core::Example] example - a RSpec example
35
+ def call(example_map, example)
36
+ paths = []
37
+ yield example_map, example
38
+ example_map.each do |path|
39
+ next unless path.end_with?('.rb')
40
+
41
+ used_consts = processor.consts_interacted_with_in(path)
42
+ paths.push(*used_files(used_consts))
43
+ end
44
+ example_map.push(*filter(paths))
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :processor, :pattern, :root_path
50
+
51
+ def used_files(used_consts)
52
+ const_definition_paths.select { |k, _| Array(used_consts).include?(k) }.values.flatten
53
+ end
54
+
55
+ def files_to_inspect
56
+ Dir.glob(File.join(root_path, '**/*.rb')).grep(pattern)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parser/current'
4
+
5
+ module Crystalball
6
+ class MapGenerator
7
+ class ParserStrategy
8
+ # Parses the given source files and adds the class and module definitions
9
+ # to the `consts_defined` array.
10
+ class Processor
11
+ def consts_defined_in(path)
12
+ self.current_scope = nil
13
+ self.consts_defined = []
14
+ parse_and_process(path)
15
+ consts_defined
16
+ end
17
+
18
+ def consts_interacted_with_in(path)
19
+ self.current_scope = nil
20
+ self.consts_interacted_with = []
21
+ parse_and_process(path)
22
+ consts_interacted_with
23
+ end
24
+
25
+ protected
26
+
27
+ attr_accessor :consts_defined, :consts_interacted_with, :current_scope
28
+
29
+ private
30
+
31
+ def on_send(node)
32
+ const = filtered_children(node).detect { |c| c.type == :const }
33
+ return unless const
34
+
35
+ add_constant_interacted(qualified_name_from_node(const), nil)
36
+ end
37
+
38
+ def on_casgn(node)
39
+ namespace, name, = node.children
40
+ scope_name = namespace ? qualified_name(qualified_name_from_node(namespace), current_scope) : current_scope
41
+ add_constant_defined(name, scope_name)
42
+ end
43
+
44
+ def on_class(node)
45
+ const, superclass, body = node.children
46
+ add_constant_interacted(qualified_name_from_node(superclass), current_scope) if superclass
47
+ process_class_or_module(const, body)
48
+ end
49
+
50
+ def on_module(node)
51
+ const, body = node.children
52
+ process_class_or_module(const, body)
53
+ end
54
+
55
+ def process_class_or_module(const, body)
56
+ const_name = qualified_name_from_node(const)
57
+ result = add_constant_defined(const_name, current_scope)
58
+ self.current_scope = result if body && nested_consts?(body)
59
+ end
60
+
61
+ def nested_consts?(node)
62
+ filtered_children(node).any? { |c| %i[const casgn].include?(c.type) || nested_consts?(c) }
63
+ end
64
+
65
+ def filtered_children(node)
66
+ return [] unless node.is_a?(Parser::AST::Node)
67
+
68
+ node.children.grep(Parser::AST::Node)
69
+ end
70
+
71
+ def parse_and_process(path)
72
+ node = Parser::CurrentRuby.parse(File.read(path))
73
+ process_node_and_children(node)
74
+ rescue Parser::SyntaxError
75
+ nil
76
+ end
77
+
78
+ # @param [AST::Node, nil] node
79
+ # @return [String, nil]
80
+ def process(node)
81
+ return if node.nil?
82
+
83
+ on_handler = :"on_#{node.type}"
84
+ __send__(on_handler, node) if respond_to?(on_handler, true)
85
+ end
86
+
87
+ def process_node_and_children(node)
88
+ process(node)
89
+ filtered_children(node).each do |child|
90
+ process_node_and_children(child)
91
+ end
92
+ end
93
+
94
+ def add_constant_defined(name, scope)
95
+ add_constant(name, scope, collection: consts_defined)
96
+ end
97
+
98
+ def add_constant_interacted(name, scope)
99
+ add_constant(name, scope, collection: consts_interacted_with)
100
+ end
101
+
102
+ def add_constant(name, scope, collection:)
103
+ collection ||= []
104
+ qualified_const_name = qualified_name(name, scope)
105
+ collection << qualified_const_name
106
+ qualified_const_name
107
+ end
108
+
109
+ # @param [Parser::AST::Node] node - :const node in format s(:const, scope, :ConstName)
110
+ # where scope can be `nil` or another :const node.
111
+ # For example, `Foo::Bar` is represented as `s(:const, s(:const, nil, :Foo), :Bar)`
112
+ def qualified_name_from_node(node)
113
+ return unless node.is_a?(Parser::AST::Node)
114
+
115
+ scope, name = node.to_a
116
+ return name.to_s unless scope
117
+
118
+ qualified_name(name, qualified_name_from_node(scope))
119
+ end
120
+
121
+ def qualified_name(name, scope = nil)
122
+ return "#{scope.sub(/\A::/, '')}::#{name}" if scope
123
+
124
+ name.to_s
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -10,12 +10,12 @@ module Crystalball
10
10
  @strategies = strategies
11
11
  end
12
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
13
+ # Calls every strategy on the given example group map and returns the modified example group map
14
+ # @param [Crystalball::ExampleGroupMap] example_group_map - initial example group map
15
+ # @return [Crystalball::ExampleGroupMap] example group map augmented by each strategy
16
+ def run(example_group_map, example, &block)
17
+ run_for_strategies(example_group_map, example, *_strategies.reverse, &block)
18
+ example_group_map
19
19
  end
20
20
 
21
21
  def method_missing(method_name, *args, &block)
@@ -32,11 +32,11 @@ module Crystalball
32
32
  @strategies
33
33
  end
34
34
 
35
- def run_for_strategies(case_map, example, *strats, &block)
36
- return yield(case_map) if strats.empty?
35
+ def run_for_strategies(example_group_map, example, *strats, &block)
36
+ return yield(example_group_map) if strats.empty?
37
37
 
38
38
  strat = strats.shift
39
- strat.call(case_map, example) { |c| run_for_strategies(c, example, *strats, &block) }
39
+ strat.call(example_group_map, example) { |c| run_for_strategies(c, example, *strats, &block) }
40
40
  end
41
41
  end
42
42
  end
@@ -17,11 +17,11 @@ module Crystalball
17
17
  # @param [String] path to map
18
18
  # @return [Crystalball::ExecutionMap]
19
19
  def load(path)
20
- meta, cases = *read_files(path).transpose
20
+ meta, example_groups = *read_files(path).transpose
21
21
 
22
22
  guard_metadata_consistency(meta)
23
23
 
24
- Object.const_get(meta.first[:type]).new(metadata: meta.first, cases: cases.inject(&:merge!))
24
+ Object.const_get(meta.first[:type]).new(metadata: meta.first, example_groups: example_groups.inject(&:merge!))
25
25
  end
26
26
 
27
27
  private
@@ -29,15 +29,15 @@ module Crystalball
29
29
  def read_files(path)
30
30
  paths = path.directory? ? path.each_child.select(&:file?) : [path]
31
31
 
32
- raise NoFilesFoundError unless paths.any?(&:exist?)
32
+ raise NoFilesFoundError, "No files or folder exists #{path}" unless paths.any?(&:exist?)
33
33
 
34
34
  paths.map do |file|
35
- metadata, *cases = file.read.split("---\n").reject(&:empty?).map do |yaml|
35
+ metadata, *example_groups = file.read.split("---\n").reject(&:empty?).map do |yaml|
36
36
  YAML.safe_load(yaml, [Symbol])
37
37
  end
38
- cases = cases.inject(&:merge!)
38
+ example_groups = example_groups.inject(&:merge!)
39
39
 
40
- [metadata, cases]
40
+ [metadata, example_groups]
41
41
  end
42
42
  end
43
43
 
@@ -61,6 +61,7 @@ module Crystalball
61
61
  #
62
62
  # @param [Hash] data to write to storage file
63
63
  def dump(data)
64
+ path.dirname.mkpath
64
65
  path.open('a') { |f| f.write YAML.dump(data) }
65
66
  end
66
67
  end
@@ -3,32 +3,33 @@
3
3
  module Crystalball
4
4
  # Class for Crystalball prediction results
5
5
  class Prediction
6
- def initialize(cases)
7
- @cases = cases
6
+ def initialize(records)
7
+ @records = records
8
8
  end
9
9
 
10
+ # When the records are something like:
11
+ # ./spec/foo ./spec/foo/bar_spec.rb
12
+ # this returns just ./spec/foo
10
13
  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
14
+ sort_by(&:length).each_with_object([]) do |c, result|
15
+ result << c unless result.any? { |r| c.start_with?(r) }
16
+ end.compact
16
17
  end
17
18
 
18
19
  def to_a
19
- cases
20
+ records
20
21
  end
21
22
 
22
23
  def method_missing(*args, &block)
23
- cases.respond_to?(*args) ? cases.public_send(*args, &block) : super
24
+ records.respond_to?(*args) ? records.public_send(*args, &block) : super
24
25
  end
25
26
 
26
27
  def respond_to_missing?(*args)
27
- cases.respond_to?(*args)
28
+ records.respond_to?(*args)
28
29
  end
29
30
 
30
31
  private
31
32
 
32
- attr_reader :cases
33
+ attr_reader :records
33
34
  end
34
35
  end
@@ -29,10 +29,12 @@ module Crystalball
29
29
  def prediction
30
30
  Prediction.new(filter(raw_prediction(diff)))
31
31
  end
32
- alias cases prediction
33
32
 
34
33
  def diff
35
- repo.diff(from, to)
34
+ @diff ||= begin
35
+ ancestor = repo.merge_base(from, to || 'HEAD').sha
36
+ repo.diff(ancestor, to)
37
+ end
36
38
  end
37
39
 
38
40
  private
@@ -45,11 +47,11 @@ module Crystalball
45
47
 
46
48
  attr_reader :repo
47
49
 
48
- def filter(raw_cases)
49
- raw_cases.compact.select { |example| example_to_file_path(example).exist? }.uniq
50
+ def filter(example_groups)
51
+ example_groups.compact.select { |example_group| extract_file_path(example_group).exist? }.uniq
50
52
  end
51
53
 
52
- def example_to_file_path(example)
54
+ def extract_file_path(example)
53
55
  repo.repo_path.join(example.split('[').first).expand_path
54
56
  end
55
57
  end