crystalball 0.5.0 → 0.6.0

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