deep-cover 0.6.4 → 0.7.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.
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeepCover
4
+ class CLI
5
+ desc 'clear [OPTIONS]', 'Clear coverage data and reports'
6
+ def clear
7
+ DeepCover.persistence.clear_directory
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+
5
+ module DeepCover
6
+ class CLI
7
+ desc 'clone [OPTIONS] [COMMAND TO RUN]', 'Gets the coverage using clone mode'
8
+ option '--output', desc: 'output folder', type: :string, default: DeepCover.config.output, aliases: '-o'
9
+ option '--reporter', desc: 'reporter to use', type: :string, default: DeepCover.config.reporter
10
+ option '--open', desc: 'open the output coverage', type: :boolean, default: CLI_DEFAULTS[:open]
11
+
12
+ def clone(*command_parts)
13
+ if command_parts.empty?
14
+ command_parts = CLI_DEFAULTS[:command]
15
+ puts "No command specified, using default of: #{command_parts.join(' ')}"
16
+ end
17
+
18
+ require_relative '../../instrumented_clone_reporter'
19
+ InstrumentedCloneReporter.new(**processed_options.merge(command: command_parts)).run
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeepCover
4
+ class CLI
5
+ # Stop parsing and treat everything as positional argument if
6
+ # we encounter an unknown option or a positional argument.
7
+ # `check_unknown_options!` (defined in cli.rb) happens first,
8
+ # so we just stop on a positional argument.
9
+ stop_on_unknown_option! :exec
10
+
11
+ desc 'exec [OPTIONS] [COMMAND TO RUN]', 'Execute the command with coverage activated'
12
+ option '--output', desc: 'output folder', type: :string, default: DeepCover.config.output, aliases: '-o'
13
+ option '--reporter', desc: 'reporter to use', type: :string, default: DeepCover.config.reporter
14
+ option '--open', desc: 'open the output coverage', type: :boolean, default: CLI_DEFAULTS[:open]
15
+
16
+ def exec(*command_parts)
17
+ if command_parts.empty?
18
+ command_parts = CLI_DEFAULTS[:command]
19
+ puts "No command specified, using default of: #{command_parts.join(' ')}"
20
+ end
21
+
22
+ DeepCover.config.set(**processed_options.slice(*DEFAULTS.keys))
23
+
24
+ require 'yaml'
25
+ env_var = {'DEEP_COVER' => 'gather',
26
+ 'DEEP_COVER_OPTIONS' => YAML.dump(DeepCover.config.to_hash_for_serialize),
27
+ }
28
+
29
+ DeepCover.delete_trackers
30
+ system(env_var, *command_parts)
31
+ exit_code = $?.exitstatus
32
+ coverage = Coverage.load
33
+ puts coverage.report(**processed_options)
34
+ exit(exit_code)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeepCover
4
+ class CLI
5
+ # Stop parsing and treat everything as positional argument if
6
+ # we encounter an unknown option or a positional argument.
7
+ # `check_unknown_options!` (defined in cli.rb) happens first,
8
+ # so we just stop on a positional argument.
9
+ stop_on_unknown_option! :gather
10
+
11
+ desc 'gather [OPTIONS] COMMAND TO RUN', 'Execute the command and gather the coverage data'
12
+ def gather(*command_parts)
13
+ if command_parts.empty?
14
+ warn set_color('`gather` needs a command to run', :red)
15
+ exit(1)
16
+ end
17
+
18
+ require 'yaml'
19
+ env_var = {'DEEP_COVER' => 'gather',
20
+ 'DEEP_COVER_OPTIONS' => YAML.dump(processed_options.slice(*DEFAULTS.keys)),
21
+ }
22
+
23
+ # Clear inspiration from Bundler's kernel_exec
24
+ # https://github.com/bundler/bundler/blob/d44d803357506895555ff97f73e60d593820a0de/lib/bundler/cli/exec.rb#L50
25
+ begin
26
+ Kernel.exec(env_var, *command_parts)
27
+ rescue Errno::EACCES, Errno::ENOEXEC
28
+ warn set_color("not executable: #{command_parts.first}", :red)
29
+ exit 126 # Default exit code for that
30
+ rescue Errno::ENOENT
31
+ warn set_color("command not found: #{command_parts.first}", :red)
32
+ exit 127 # Default exit code for that
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeepCover
4
+ class CLI
5
+ # Thor comes with a built-in help command. When displaying full help, it calls the class' help
6
+ # We override it to make it more to our taste.
7
+
8
+ # Copied from Thor's default one, then customized
9
+ def self.help(shell, subcommand = false)
10
+ list = printable_commands(true, subcommand)
11
+ Thor::Util.thor_classes_in(self).each do |klass|
12
+ list += klass.printable_commands(false)
13
+ end
14
+ list.sort! { |a, b| a[0] <=> b[0] }
15
+
16
+ main_commands, list = Tools.extract_commands_for_help(list, :exec, :clone)
17
+
18
+ shell.say 'Main commands:'
19
+ shell.print_table(main_commands, indent: 2, truncate: true)
20
+
21
+ lower_level_commands, list = Tools.extract_commands_for_help(list, :gather, :report, :clear, :merge)
22
+ shell.say
23
+ shell.say 'Lower-level commands:'
24
+ shell.print_table(lower_level_commands, indent: 2, truncate: true)
25
+
26
+ shell.say
27
+ shell.say 'Misc commands:'
28
+ shell.print_table(list, indent: 2, truncate: true)
29
+
30
+ shell.say
31
+ class_options_help(shell)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeepCover
4
+ class CLI
5
+ desc 'merge [OPTIONS]', 'Merge all multiple coverage data into one (for easier moving around)'
6
+ def merge
7
+ DeepCover.persistence.merge_persisted_trackers
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeepCover
4
+ class CLI
5
+ desc 'report [OPTIONS]', 'Generates report from coverage data that was gathered by previous commands'
6
+ option '--output', desc: 'output folder', type: :string, default: DeepCover.config.output, aliases: '-o'
7
+ option '--reporter', desc: 'reporter to use', type: :string, default: DeepCover.config.reporter
8
+ option '--open', desc: 'open the output coverage', type: :boolean, default: CLI_DEFAULTS[:open]
9
+ def report
10
+ coverage = Coverage.load
11
+ puts coverage.report(**processed_options)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeepCover
4
+ class CLI
5
+ desc "run-expression [OPTIONS] 'ruby code'", 'Show coverage results for the given ruby expression'
6
+ option '--profile', desc: 'use profiler', type: :boolean if RUBY_PLATFORM != 'java'
7
+ option '--debug', aliases: '-d', desc: 'opens an interactive console for debugging', type: :boolean
8
+ def run_expression(expression)
9
+ require_relative '../expression_debugger'
10
+ ExpressionDebugger.new(expression, **options.transform_keys(&:to_sym)).show
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeepCover
4
+ class CLI
5
+ desc 'short-help', 'Display a short help. Same as just `deep-cover`'
6
+ def short_help
7
+ self.class.short_help(shell)
8
+ end
9
+
10
+ def self.short_help(shell)
11
+ list = printable_commands(true, false)
12
+ Thor::Util.thor_classes_in(self).each do |klass|
13
+ list += klass.printable_commands(false)
14
+ end
15
+
16
+ main_commands, _ignored = Tools.extract_commands_for_help(list, :exec, :clone)
17
+
18
+ shell.say 'Main command:'
19
+ shell.print_table(main_commands, indent: 2, truncate: true)
20
+
21
+ shell.say
22
+ shell.say 'Use `deep-cover help` for a full help with a list of lower-level commands'
23
+ shell.say 'and information on the available options'
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeepCover
4
+ class CLI
5
+ # Add some top-level aliases for this command
6
+ # So it's possible to do `deep-cover -v` or `deep-cover --version`
7
+ map %w(-v --version) => :version
8
+
9
+ desc 'version', "Print deep-cover's version"
10
+ def version
11
+ require 'deep_cover/version'
12
+ puts "deep-cover version #{DeepCover::VERSION}"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeepCover
4
+ class CLI
5
+ module Tools
6
+ # Extracts the commands for the help by name, in same order as the names.
7
+ # This make handling the output of printable_commands more easily.
8
+ # Returns the matching and the remaining commands.
9
+ def self.extract_commands_for_help(commands, *names)
10
+ matching = names.map do |name|
11
+ commands.detect { |usage, desc| usage.start_with?("deep-cover #{name}") }
12
+ end
13
+ remains = commands - matching
14
+ [matching, remains]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeepCover
4
+ Tools.silence_warnings do
5
+ require 'with_progress'
6
+ end
7
+ module Tools::CoverClonedTree
8
+ def cover_cloned_tree(original_paths, original_root:, clone_root:) # &block
9
+ # Make sure the directories end with a '/' for safe replacing
10
+ original_root = File.join(File.expand_path(original_root), '')
11
+ clone_root = File.join(File.expand_path(clone_root), '')
12
+
13
+ paths_with_bad_syntax = []
14
+ original_paths, paths_not_in_root = original_paths.partition { |path| path.start_with?(original_root) }
15
+
16
+ original_paths.each.with_progress(title: 'Rewriting') do |original_path|
17
+ clone_path = Pathname(original_path.sub(original_root, clone_root))
18
+ begin
19
+ source = clone_path.read
20
+ # We need to use the original_path so that the tracker_paths inserted in the files
21
+ # during the instrumentation are for the original paths
22
+ covered_code = DeepCover.coverage.covered_code(original_path, source: source)
23
+ rescue Parser::SyntaxError
24
+ paths_with_bad_syntax << original_path
25
+ next
26
+ end
27
+
28
+ # Allow a passed block to edit the source that will be written
29
+ covered_source = covered_code.covered_source
30
+ covered_source = yield covered_source, original_path, clone_path if block_given?
31
+
32
+ clone_path.dirname.mkpath
33
+ clone_path.write(covered_source)
34
+ end
35
+
36
+ unless paths_with_bad_syntax.empty?
37
+ warn [
38
+ "#{paths_with_bad_syntax.size} files could not be instrumented because of syntax errors:",
39
+ *paths_with_bad_syntax.first(3),
40
+ ('...' if paths_with_bad_syntax.size > 3),
41
+ ].compact.join("\n")
42
+ end
43
+
44
+ unless paths_not_in_root.empty?
45
+ warn [
46
+ "#{paths_not_in_root.size} files could not be instrumented because they are not within the directory being cloned.",
47
+ "(Consider configuring DeepCover's paths to to avoid those files being included)",
48
+ *paths_not_in_root.first(5),
49
+ ('...' if paths_not_in_root.size > 5),
50
+ ].compact.join("\n")
51
+ end
52
+
53
+ nil
54
+ end
55
+ end
56
+
57
+ Tools.extend Tools::CoverClonedTree
58
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeepCover
4
+ class ExpressionDebugger
5
+ include Tools
6
+
7
+ module ColorAST
8
+ def fancy_type
9
+ color = case
10
+ when !executable?
11
+ :faint
12
+ when !was_executed?
13
+ :red
14
+ when flow_interrupt_count > 0
15
+ :yellow
16
+ else
17
+ :green
18
+ end
19
+ Term::ANSIColor.send(color, super)
20
+ end
21
+ end
22
+
23
+ attr_reader :options
24
+ def initialize(source, filename: '(source)', lineno: 1, debug: false, **options)
25
+ @source = source
26
+ @filename = filename
27
+ @lineno = lineno
28
+ @debug = debug
29
+ @options = options
30
+ end
31
+
32
+ def show
33
+ Tools.profile(options[:profile]) do
34
+ execute
35
+ covered_code.freeze # Our output relies on the counts, so better freeze. See [#13]
36
+ if @debug
37
+ show_line_coverage
38
+ show_instrumented_code
39
+ show_ast
40
+ end
41
+ show_char_coverage
42
+ end
43
+ pry if @debug
44
+ finish
45
+ end
46
+
47
+ def show_line_coverage
48
+ output { "Line Coverage: Builtin | DeepCover | DeepCover Strict:\n" }
49
+ begin
50
+ builtin_line_coverage = builtin_coverage(@source, @filename, @lineno)
51
+ our_line_coverage = our_coverage(@source, @filename, @lineno, **options)
52
+ our_strict_line_coverage = our_coverage(@source, @filename, @lineno, allow_partial: false, **options)
53
+ output do
54
+ lines = format(builtin_line_coverage, our_line_coverage, our_strict_line_coverage, source: @source)
55
+ number_lines(lines, lineno: @lineno)
56
+ end
57
+ rescue Exception => e
58
+ output { "Can't run coverage: #{e.class}: #{e}\n#{e.backtrace.join("\n")}" }
59
+ @failed = true
60
+ end
61
+ end
62
+
63
+ def show_instrumented_code
64
+ output { "\nInstrumented code:\n" }
65
+ output { format_generated_code(covered_code) }
66
+ end
67
+
68
+ def show_ast
69
+ output { "\nParsed code:\n" }
70
+ Node.prepend ColorAST
71
+ output { covered_code.covered_ast }
72
+ end
73
+
74
+ def show_char_coverage
75
+ output { "\nNode coverage:\n" }
76
+
77
+ output { format_char_cover(covered_code, show_whitespace: !!ENV['W'], **options) }
78
+ end
79
+
80
+ def pry
81
+ a = covered_code.covered_ast
82
+ b = a.children.first
83
+ ::DeepCover.load_pry
84
+ binding.pry
85
+ end
86
+
87
+ def finish
88
+ exit(!@failed)
89
+ end
90
+
91
+ def covered_code
92
+ @covered_code ||= CoveredCode.new(source: @source, path: @filename, lineno: @lineno)
93
+ end
94
+
95
+ def execute
96
+ execute_sample(covered_code)
97
+ # output { trace_counts } # Keep for low-level debugging purposes
98
+ rescue Exception => e
99
+ output { "Can't `execute_sample`:#{e.class}: #{e}\n#{e.backtrace.join("\n")}" }
100
+ @failed = true
101
+ end
102
+
103
+ def trace_counts
104
+ all = []
105
+ trace = TracePoint.new(:call) do |tr|
106
+ if %i[flow_entry_count flow_completion_count execution_count].include? tr.method_id
107
+ node = tr.self
108
+ str = "#{node.type} #{(node.value if node.respond_to?(:value))} #{tr.method_id}"
109
+ all << str unless all.last == str
110
+ end
111
+ end
112
+ trace.enable { covered_code.freeze }
113
+ all
114
+ end
115
+
116
+ def output
117
+ Tools.dont_profile do
118
+ puts yield
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+
5
+ module DeepCover
6
+ require_relative 'cover_cloned_tree'
7
+
8
+ class InstrumentedCloneReporter
9
+ include Tools
10
+ # matches regular files, .files, ..files, but not '.' or '..'
11
+ GLOB_ALL_CONTENT = '{,.[^.],..?}*'
12
+
13
+ def initialize(**options)
14
+ @options = CLI_DEFAULTS.merge(options)
15
+ @root_path = @source_path = Pathname.new('.').expand_path
16
+ if !@root_path.join('Gemfile').exist? && @root_path.dirname.join('Gemfile').exist?
17
+ # E.g. rails/activesupport
18
+ @root_path = @root_path.dirname
19
+ end
20
+ path = Pathname('~/test_deep_cover').expand_path
21
+ if path.exist?
22
+ @dest_root = path.join(@source_path.basename)
23
+ @dest_root.mkpath
24
+ else
25
+ @dest_root = Pathname.new(Dir.mktmpdir('deep_cover_test'))
26
+ end
27
+
28
+ gem_relative_path = @source_path.relative_path_from(@root_path)
29
+ @main_path = @dest_root.join(gem_relative_path)
30
+ end
31
+
32
+ def clear
33
+ FileUtils.rm_rf(Dir.glob("#{@dest_root}/#{GLOB_ALL_CONTENT}"))
34
+ end
35
+
36
+ def copy
37
+ return true if @copied
38
+ puts 'Cloning...'
39
+ FileUtils.cp_r(Dir.glob("#{@root_path}/#{GLOB_ALL_CONTENT}"), @dest_root)
40
+ @copied = true
41
+ end
42
+
43
+ def create_entry_point_file
44
+ require 'tempfile'
45
+ file = Tempfile.new(['deep_cover_entry_point', '.rb'])
46
+ template = File.read(DeepCover::CORE_GEM_LIB_DIRECTORY + '/deep_cover/setup/clone_mode_entry_template.rb')
47
+
48
+ cache_directory = DeepCover.config.cache_directory.to_s
49
+ tracker_global = DeepCover.config.tracker_global
50
+
51
+ # Those are the fake global variables that we actually replace as we copy the template over
52
+ template.gsub!('$_cache_directory', cache_directory.inspect)
53
+ template.gsub!('$_global_name', tracker_global.inspect)
54
+ template.gsub!('$_core_gem_lib_directory', DeepCover::CORE_GEM_LIB_DIRECTORY.inspect)
55
+
56
+ file.write(template)
57
+ file.close
58
+
59
+ # We dont want the file to be removed, this way it stays as long as the clones directory does.
60
+ ObjectSpace.undefine_finalizer(file)
61
+
62
+ file.path
63
+ end
64
+
65
+ def patch_rubocop
66
+ path = @dest_root.join('.rubocop.yml')
67
+ return unless path.exist?
68
+ puts 'Patching .rubocop.yml'
69
+ config = YAML.load(path.read)
70
+ all_cop_excludes = ((config['AllCops'] ||= {})['Exclude'] ||= [])
71
+
72
+ # Ensure they end with a '/'
73
+ original_root = File.join(File.expand_path(@root_path), '')
74
+ clone_root = File.join(File.expand_path(@dest_root), '')
75
+
76
+ paths_to_ignore = DeepCover.all_tracked_file_paths
77
+ paths_to_ignore.select! { |p| p.start_with?(original_root) }
78
+ paths_to_ignore.map! { |p| p.sub(original_root, clone_root) }
79
+
80
+ all_cop_excludes.concat(paths_to_ignore)
81
+ path.write("# This file was modified by DeepCover\n" + YAML.dump(config))
82
+ end
83
+
84
+ def patch
85
+ patch_rubocop
86
+ end
87
+
88
+ def remove_deep_cover_config
89
+ path = @dest_root.join('.deep_cover.rb')
90
+ return unless path.exist?
91
+ File.delete(path)
92
+ end
93
+
94
+ def cover
95
+ entry_point_path = create_entry_point_file
96
+ Tools.cover_cloned_tree(DeepCover.all_tracked_file_paths,
97
+ clone_root: @dest_root,
98
+ original_root: @root_path) do |source|
99
+ source.sub(/\A(#.*\n|\s+)*/) do |header|
100
+ "#{header}require #{entry_point_path.inspect};"
101
+ end
102
+ end
103
+ end
104
+
105
+ def process
106
+ DeepCover.delete_trackers
107
+ system({'DISABLE_SPRING' => 'true', 'DEEP_COVER_OPTIONS' => nil}, *@options[:command], chdir: @main_path)
108
+ $?.exitstatus
109
+ end
110
+
111
+ def report
112
+ coverage = Coverage.load
113
+ puts coverage.report(**@options)
114
+ end
115
+
116
+ def run
117
+ clear
118
+ copy
119
+ cover
120
+ patch
121
+ remove_deep_cover_config
122
+ exit_code = process
123
+ report
124
+ exit(exit_code)
125
+ end
126
+ end
127
+ end