simplecov 0.22.0 → 1.3.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 +4 -4
- data/LICENSE +1 -1
- data/README.md +173 -875
- data/exe/simplecov +8 -0
- data/lib/minitest/simplecov_plugin.rb +18 -7
- data/lib/simplecov/atomic_file.rb +67 -0
- data/lib/simplecov/autostart.rb +9 -0
- data/lib/simplecov/baseline/parser.rb +58 -0
- data/lib/simplecov/baseline.rb +170 -0
- data/lib/simplecov/cli/affected/changed_files.rb +57 -0
- data/lib/simplecov/cli/affected/selection.rb +117 -0
- data/lib/simplecov/cli/affected.rb +148 -0
- data/lib/simplecov/cli/annotations.rb +166 -0
- data/lib/simplecov/cli/badge/svg.rb +70 -0
- data/lib/simplecov/cli/badge.rb +62 -0
- data/lib/simplecov/cli/clean.rb +86 -0
- data/lib/simplecov/cli/command_helpers.rb +91 -0
- data/lib/simplecov/cli/completions/scripts.rb +114 -0
- data/lib/simplecov/cli/completions.rb +83 -0
- data/lib/simplecov/cli/coverage.rb +83 -0
- data/lib/simplecov/cli/coverage_file.rb +109 -0
- data/lib/simplecov/cli/dead_code/output.rb +100 -0
- data/lib/simplecov/cli/dead_code.rb +139 -0
- data/lib/simplecov/cli/diff/output.rb +45 -0
- data/lib/simplecov/cli/diff.rb +115 -0
- data/lib/simplecov/cli/dotfile.rb +156 -0
- data/lib/simplecov/cli/git.rb +41 -0
- data/lib/simplecov/cli/history/output.rb +135 -0
- data/lib/simplecov/cli/history.rb +67 -0
- data/lib/simplecov/cli/merge.rb +115 -0
- data/lib/simplecov/cli/open.rb +45 -0
- data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
- data/lib/simplecov/cli/patch/output.rb +151 -0
- data/lib/simplecov/cli/patch.rb +175 -0
- data/lib/simplecov/cli/ratchet/output.rb +44 -0
- data/lib/simplecov/cli/ratchet.rb +83 -0
- data/lib/simplecov/cli/report.rb +91 -0
- data/lib/simplecov/cli/run.rb +35 -0
- data/lib/simplecov/cli/serve/report_preparer.rb +30 -0
- data/lib/simplecov/cli/serve/static_file_handler.rb +128 -0
- data/lib/simplecov/cli/serve.rb +88 -0
- data/lib/simplecov/cli/show/annotator.rb +87 -0
- data/lib/simplecov/cli/show/sweep.rb +36 -0
- data/lib/simplecov/cli/show.rb +133 -0
- data/lib/simplecov/cli/status/facts.rb +87 -0
- data/lib/simplecov/cli/status.rb +93 -0
- data/lib/simplecov/cli/tests/redundancy.rb +78 -0
- data/lib/simplecov/cli/tests.rb +148 -0
- data/lib/simplecov/cli/uncovered/misses.rb +33 -0
- data/lib/simplecov/cli/uncovered.rb +127 -0
- data/lib/simplecov/cli/usage.rb +153 -0
- data/lib/simplecov/cli/watch/live_report.rb +73 -0
- data/lib/simplecov/cli/watch/narrator.rb +55 -0
- data/lib/simplecov/cli/watch/poller.rb +45 -0
- data/lib/simplecov/cli/watch/session.rb +134 -0
- data/lib/simplecov/cli/watch/test_plan.rb +43 -0
- data/lib/simplecov/cli/watch.rb +85 -0
- data/lib/simplecov/cli.rb +114 -0
- data/lib/simplecov/color.rb +65 -0
- data/lib/simplecov/combine/branches_combiner.rb +44 -21
- data/lib/simplecov/combine/coverage_accumulator.rb +175 -0
- data/lib/simplecov/combine/identity_interner.rb +27 -0
- data/lib/simplecov/combine/interned_counts.rb +28 -0
- data/lib/simplecov/combine/lines_combiner.rb +35 -29
- data/lib/simplecov/combine/methods_combiner.rb +48 -0
- data/lib/simplecov/combine/results_combiner.rb +9 -49
- data/lib/simplecov/combine.rb +0 -24
- data/lib/simplecov/command_guesser.rb +99 -42
- data/lib/simplecov/configuration/baseline.rb +31 -0
- data/lib/simplecov/configuration/coverage.rb +184 -0
- data/lib/simplecov/configuration/coverage_criteria.rb +143 -0
- data/lib/simplecov/configuration/deprecations.rb +21 -0
- data/lib/simplecov/configuration/eval_coverage.rb +37 -0
- data/lib/simplecov/configuration/filters.rb +131 -0
- data/lib/simplecov/configuration/formatting.rb +129 -0
- data/lib/simplecov/configuration/groups.rb +48 -0
- data/lib/simplecov/configuration/history.rb +44 -0
- data/lib/simplecov/configuration/ignored_entries.rb +72 -0
- data/lib/simplecov/configuration/merging.rb +197 -0
- data/lib/simplecov/configuration/missed_caps.rb +51 -0
- data/lib/simplecov/configuration/production.rb +23 -0
- data/lib/simplecov/configuration/test_tracking.rb +48 -0
- data/lib/simplecov/configuration/thresholds.rb +150 -0
- data/lib/simplecov/configuration/view_coverage.rb +37 -0
- data/lib/simplecov/configuration.rb +100 -443
- data/lib/simplecov/configuration_error.rb +5 -0
- data/lib/simplecov/context_map/union.rb +81 -0
- data/lib/simplecov/context_map.rb +187 -0
- data/lib/simplecov/coverage_json.rb +21 -0
- data/lib/simplecov/coverage_statistics.rb +24 -28
- data/lib/simplecov/coverage_violations.rb +254 -0
- data/lib/simplecov/current_run.rb +61 -0
- data/lib/simplecov/defaults.rb +20 -28
- data/lib/simplecov/deprecation.rb +63 -0
- data/lib/simplecov/directive/erb.rb +44 -0
- data/lib/simplecov/directive/haml.rb +33 -0
- data/lib/simplecov/directive/indented_template.rb +37 -0
- data/lib/simplecov/directive/slim.rb +35 -0
- data/lib/simplecov/directive/template.rb +29 -0
- data/lib/simplecov/directive.rb +152 -0
- data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
- data/lib/simplecov/exit_codes/check.rb +39 -0
- data/lib/simplecov/exit_codes/exit_code_handling.rb +24 -5
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +17 -67
- data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
- data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +27 -0
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +20 -38
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +28 -0
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -34
- data/lib/simplecov/exit_codes.rb +26 -0
- data/lib/simplecov/exit_handling.rb +163 -0
- data/lib/simplecov/file_list.rb +63 -48
- data/lib/simplecov/filter.rb +98 -42
- data/lib/simplecov/formatter/base.rb +100 -0
- data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
- data/lib/simplecov/formatter/coverage_json_writer.rb +95 -0
- data/lib/simplecov/formatter/html_formatter/public/index.html +93 -0
- data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +153 -0
- data/lib/simplecov/formatter/html_formatter.rb +88 -0
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +122 -0
- data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +132 -0
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +97 -0
- data/lib/simplecov/formatter/json_formatter.rb +40 -0
- data/lib/simplecov/formatter/multi_formatter.rb +8 -10
- data/lib/simplecov/formatter/simple_formatter.rb +12 -15
- data/lib/simplecov/formatter.rb +21 -1
- data/lib/simplecov/group_names.rb +33 -0
- data/lib/simplecov/history.rb +115 -0
- data/lib/simplecov/last_run.rb +16 -5
- data/lib/simplecov/lines_classifier.rb +67 -19
- data/lib/simplecov/load_global_config.rb +12 -4
- data/lib/simplecov/parallel_adapters/base.rb +50 -0
- data/lib/simplecov/parallel_adapters/generic.rb +33 -0
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +72 -0
- data/lib/simplecov/parallel_adapters.rb +54 -0
- data/lib/simplecov/parallel_coordination.rb +124 -0
- data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
- data/lib/simplecov/parallel_result_merger.rb +171 -0
- data/lib/simplecov/process.rb +34 -12
- data/lib/simplecov/production/error.rb +11 -0
- data/lib/simplecov/production/file_sink.rb +130 -0
- data/lib/simplecov/production.rb +249 -0
- data/lib/simplecov/profiles/bundler_filter.rb +1 -1
- data/lib/simplecov/profiles/hidden_filter.rb +1 -1
- data/lib/simplecov/profiles/rails.rb +13 -10
- data/lib/simplecov/profiles/root_filter.rb +2 -5
- data/lib/simplecov/profiles/strict.rb +22 -0
- data/lib/simplecov/profiles/test_frameworks.rb +1 -4
- data/lib/simplecov/profiles.rb +26 -19
- data/lib/simplecov/report_deferral.rb +45 -0
- data/lib/simplecov/report_stamp.rb +25 -0
- data/lib/simplecov/result/filter_config.rb +19 -0
- data/lib/simplecov/result/missing_source_files_reporter.rb +48 -0
- data/lib/simplecov/result/serialization.rb +43 -0
- data/lib/simplecov/result/source_file_builder.rb +46 -0
- data/lib/simplecov/result.rb +123 -47
- data/lib/simplecov/result_adapter.rb +130 -11
- data/lib/simplecov/result_merger/contexts.rb +22 -0
- data/lib/simplecov/result_merger/legacy_format_adapter.rb +24 -0
- data/lib/simplecov/result_merger/resultset_file.rb +67 -0
- data/lib/simplecov/result_merger/resultset_run_identity.rb +65 -0
- data/lib/simplecov/result_merger/resultset_store.rb +62 -0
- data/lib/simplecov/result_merger/unloaded_files.rb +95 -0
- data/lib/simplecov/result_merger.rb +120 -124
- data/lib/simplecov/result_processing.rb +188 -0
- data/lib/simplecov/run_identity.rb +75 -0
- data/lib/simplecov/simulate_coverage.rb +72 -17
- data/lib/simplecov/source_file/branch.rb +8 -35
- data/lib/simplecov/source_file/branch_builder.rb +95 -0
- data/lib/simplecov/source_file/builder_context.rb +28 -0
- data/lib/simplecov/source_file/line.rb +17 -32
- data/lib/simplecov/source_file/line_builder.rb +37 -0
- data/lib/simplecov/source_file/method.rb +54 -0
- data/lib/simplecov/source_file/method_builder.rb +51 -0
- data/lib/simplecov/source_file/ruby_data_parser.rb +103 -0
- data/lib/simplecov/source_file/skip_chunks.rb +72 -0
- data/lib/simplecov/source_file/source_loader.rb +89 -0
- data/lib/simplecov/source_file/statistics.rb +76 -0
- data/lib/simplecov/source_file.rb +64 -260
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +155 -0
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +191 -0
- data/lib/simplecov/static_coverage_extractor/method_collector.rb +45 -0
- data/lib/simplecov/static_coverage_extractor/prism_compat.rb +46 -0
- data/lib/simplecov/static_coverage_extractor/value_position.rb +61 -0
- data/lib/simplecov/static_coverage_extractor/visitor.rb +163 -0
- data/lib/simplecov/static_coverage_extractor.rb +78 -0
- data/lib/simplecov/test_tracker/accessors.rb +34 -0
- data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
- data/lib/simplecov/test_tracker/delta.rb +69 -0
- data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
- data/lib/simplecov/test_tracker.rb +162 -0
- data/lib/simplecov/unloaded_file_injector.rb +62 -0
- data/lib/simplecov/useless_results_remover.rb +17 -7
- data/lib/simplecov/version.rb +1 -1
- data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
- data/lib/simplecov/view_coverage.rb +55 -0
- data/lib/simplecov-html.rb +5 -0
- data/lib/simplecov.rb +143 -394
- data/lib/simplecov_json_formatter.rb +5 -0
- data/man/simplecov.1 +354 -0
- data/sig/simplecov.rbs +1710 -0
- metadata +183 -61
- data/CHANGELOG.md +0 -191
- data/doc/alternate-formatters.md +0 -71
- data/doc/commercial-services.md +0 -25
- data/doc/editor-integration.md +0 -18
- data/lib/simplecov/combine/files_combiner.rb +0 -24
- data/lib/simplecov/default_formatter.rb +0 -20
data/exe/simplecov
ADDED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# How minitest plugins. See https://github.com/simplecov-ruby/simplecov/pull/756 for why we need this.
|
|
4
|
-
# https://github.com/seattlerb/minitest#writing-extensions
|
|
5
3
|
module Minitest
|
|
4
|
+
# Handles the SimpleCov-first / Minitest-second ordering: `SimpleCov.start`
|
|
5
|
+
# runs before `require "minitest/autorun"`, so its start-time detection can't
|
|
6
|
+
# see Minitest yet, but by the time this plugin fires Minitest is loaded and
|
|
7
|
+
# the same switch can be flipped. The opposite ordering is handled in
|
|
8
|
+
# `SimpleCov.install_at_exit_hook` (#756).
|
|
9
|
+
#
|
|
10
|
+
# The respond_to? guards cover a process where only simplecov's version file
|
|
11
|
+
# was loaded (#877), which defines the SimpleCov constant without any of its
|
|
12
|
+
# configuration.
|
|
6
13
|
def self.plugin_simplecov_init(_options)
|
|
7
|
-
|
|
8
|
-
SimpleCov.external_at_exit = true
|
|
14
|
+
return unless defined?(SimpleCov)
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
SimpleCov::TestTracker.install_minitest_hook if SimpleCov.respond_to?(:track_tests?) && SimpleCov.track_tests?
|
|
17
|
+
|
|
18
|
+
return if SimpleCov.external_at_exit?
|
|
19
|
+
|
|
20
|
+
SimpleCov.external_at_exit = true
|
|
21
|
+
|
|
22
|
+
Minitest.after_run do
|
|
23
|
+
SimpleCov.at_exit_behavior
|
|
13
24
|
end
|
|
14
25
|
end
|
|
15
26
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module SimpleCov
|
|
7
|
+
# Replaces an artifact through a collision-safe temporary file in the
|
|
8
|
+
# destination directory, so readers see either the old or the complete new
|
|
9
|
+
# file.
|
|
10
|
+
module AtomicFile
|
|
11
|
+
DEFAULT_MODE = 0o666
|
|
12
|
+
private_constant :DEFAULT_MODE
|
|
13
|
+
|
|
14
|
+
def self.write(path, content, binary: false)
|
|
15
|
+
directory = File.dirname(path)
|
|
16
|
+
FileUtils.mkdir_p(directory)
|
|
17
|
+
mode = destination_mode(path)
|
|
18
|
+
|
|
19
|
+
Tempfile.create([".simplecov-", ".tmp"], directory) do |temp|
|
|
20
|
+
replace(temp, path, content, mode, binary: binary)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Closing flushes the last buffered bytes, so the rename publishes a complete
|
|
27
|
+
# file rather than whatever happened to reach disk. No cleanup of its own:
|
|
28
|
+
# `Tempfile.create` closes and removes the file when its block ends, however
|
|
29
|
+
# it ends.
|
|
30
|
+
def self.replace(temp, path, content, mode, binary:)
|
|
31
|
+
temp.binmode if binary
|
|
32
|
+
temp.write(content)
|
|
33
|
+
temp.close
|
|
34
|
+
File.chmod(mode, temp)
|
|
35
|
+
rename_over(temp, path)
|
|
36
|
+
end
|
|
37
|
+
private_class_method :replace
|
|
38
|
+
|
|
39
|
+
# On POSIX systems rename replaces the destination atomically. On Windows it
|
|
40
|
+
# fails with EACCES when the destination is open in a reader or
|
|
41
|
+
# mid-replacement by a concurrent writer, so retry briefly there.
|
|
42
|
+
def self.rename_over(temp, path)
|
|
43
|
+
remaining = 10
|
|
44
|
+
begin
|
|
45
|
+
File.rename(temp, path)
|
|
46
|
+
rescue Errno::EACCES
|
|
47
|
+
raise unless Gem.win_platform?
|
|
48
|
+
|
|
49
|
+
remaining -= 1
|
|
50
|
+
raise if remaining.zero?
|
|
51
|
+
|
|
52
|
+
sleep(0.01)
|
|
53
|
+
retry
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
private_class_method :rename_over
|
|
57
|
+
|
|
58
|
+
def self.destination_mode(path)
|
|
59
|
+
return DEFAULT_MODE & ~File.umask if File.symlink?(path)
|
|
60
|
+
|
|
61
|
+
File.stat(path).mode & 0o777
|
|
62
|
+
rescue Errno::ENOENT
|
|
63
|
+
DEFAULT_MODE & ~File.umask
|
|
64
|
+
end
|
|
65
|
+
private_class_method :destination_mode
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded via `RUBYOPT="-rsimplecov/autostart"` from `simplecov run`. The require
|
|
4
|
+
# below also auto-loads `~/.simplecov` and the project's `.simplecov`, which may
|
|
5
|
+
# already call `SimpleCov.start`. That call is idempotent, so making it
|
|
6
|
+
# unconditionally is the safe way to ensure the report is formatted at exit.
|
|
7
|
+
|
|
8
|
+
require "simplecov"
|
|
9
|
+
SimpleCov.start
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
class Baseline
|
|
5
|
+
# Turns the YAML document of a baseline file into the
|
|
6
|
+
# `{path => {criterion => Floor}}` entries `Baseline` holds. The canonical
|
|
7
|
+
# shape is the criteria Hash `to_yaml` writes, but the lenient shapes stay
|
|
8
|
+
# readable: a bare number is a line-percent floor, and a criterion may carry
|
|
9
|
+
# a bare percent instead of the percent/missed pair. Anything else raises
|
|
10
|
+
# ConfigurationError, naming the file and the offending entry: a malformed
|
|
11
|
+
# policy must fail loudly rather than silently un-enforce every floor.
|
|
12
|
+
module Parser
|
|
13
|
+
extend self
|
|
14
|
+
|
|
15
|
+
def call(data, path)
|
|
16
|
+
raise ConfigurationError, "baseline file #{path} must map file paths to floors" unless data.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
data.to_h { |file, entry| [file.to_s, parse_entry(file, entry, path)] }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def parse_entry(file, entry, path)
|
|
22
|
+
return {line: Floor.new(percent: entry, missed: nil)} if entry.is_a?(Numeric)
|
|
23
|
+
|
|
24
|
+
unless entry.is_a?(Hash)
|
|
25
|
+
raise ConfigurationError, "baseline file #{path}: entry for #{file} must be a number or a criteria Hash"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
entry.to_h do |criterion_key, floor|
|
|
29
|
+
[parse_criterion(file, criterion_key, path), parse_floor(file, floor, path)]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def parse_criterion(file, criterion_key, path)
|
|
34
|
+
CRITERIA[criterion_key.to_s] || raise(
|
|
35
|
+
ConfigurationError,
|
|
36
|
+
"baseline file #{path}: unknown criterion #{criterion_key.inspect} for #{file} " \
|
|
37
|
+
"(expected lines, branches, or methods)"
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_floor(file, floor, path)
|
|
42
|
+
return Floor.new(percent: floor, missed: nil) if floor.is_a?(Numeric)
|
|
43
|
+
|
|
44
|
+
percent = floor["percent"] if floor.is_a?(Hash)
|
|
45
|
+
unless percent.is_a?(Numeric)
|
|
46
|
+
raise ConfigurationError, "baseline file #{path}: floor for #{file} needs a numeric percent"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
missed = floor["missed"]
|
|
50
|
+
unless missed.nil? || missed.instance_of?(Integer)
|
|
51
|
+
raise ConfigurationError, "baseline file #{path}: missed count for #{file} must be an integer"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
Floor.new(percent: percent, missed: missed)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module SimpleCov
|
|
6
|
+
# The checked-in per-file coverage floor behind `simplecov ratchet` (#1268):
|
|
7
|
+
# `.rubocop_todo.yml` applied to coverage. Each entry records, per criterion,
|
|
8
|
+
# the percent a file has already reached and the number of misses it had when
|
|
9
|
+
# that percent was recorded.
|
|
10
|
+
#
|
|
11
|
+
# A floor carries both numbers because each covers for the other's blind
|
|
12
|
+
# spot. The percent is the policy, but a percent moves when a file is edited
|
|
13
|
+
# without any coverage change at all, so the missed count acts as the
|
|
14
|
+
# dampener. A violation therefore requires both a lower percent and more
|
|
15
|
+
# misses, which is the honest reading of "this file got worse".
|
|
16
|
+
class Baseline
|
|
17
|
+
DEFAULT_FILENAME = ".simplecov_baseline.yml"
|
|
18
|
+
|
|
19
|
+
HEADER = <<~HEADER
|
|
20
|
+
# Per-file coverage floors. A file that drops below its floor fails the run,
|
|
21
|
+
# and files with no entry fall through to the global per-file minimum.
|
|
22
|
+
# Regenerate with `simplecov ratchet`. Floors only ever tighten.
|
|
23
|
+
HEADER
|
|
24
|
+
private_constant :HEADER
|
|
25
|
+
|
|
26
|
+
# The YAML file speaks the report's vocabulary; internally floors are keyed
|
|
27
|
+
# by criterion the way the threshold configuration is.
|
|
28
|
+
CRITERIA = {"lines" => :line, "branches" => :branch, "methods" => :method}.freeze
|
|
29
|
+
private_constant :CRITERIA
|
|
30
|
+
|
|
31
|
+
# `missed` is nil for a hand-written percent-only entry, meaning the
|
|
32
|
+
# dampener is absent and the percent alone decides.
|
|
33
|
+
Floor = Data.define(:percent, :missed)
|
|
34
|
+
|
|
35
|
+
# The tightened baseline plus the path lists the CLI summarizes (each path
|
|
36
|
+
# appears in exactly one).
|
|
37
|
+
Outcome = Data.define(:baseline, :tightened, :pruned, :regressed, :unchanged)
|
|
38
|
+
|
|
39
|
+
# A file that exists but cannot be read as a baseline raises
|
|
40
|
+
# ConfigurationError: a malformed policy must fail loudly rather than
|
|
41
|
+
# silently un-enforce every floor it carried.
|
|
42
|
+
def self.read_if_exists(path)
|
|
43
|
+
return nil unless File.exist?(path)
|
|
44
|
+
|
|
45
|
+
new(Parser.call(YAML.safe_load_file(path), path))
|
|
46
|
+
rescue Psych::Exception => e
|
|
47
|
+
# Interpolating the exception renders the message Psych objected with.
|
|
48
|
+
raise ConfigurationError, "baseline file #{path} is not valid YAML: #{e}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# `current` is a `{project_filename => {criterion => {percent:, missed:}}}`
|
|
52
|
+
# Hash of the measured state. Everything the report carries gets a floor at
|
|
53
|
+
# its current coverage.
|
|
54
|
+
def self.read(path)
|
|
55
|
+
Deprecation.warn("`SimpleCov::Baseline.read` is deprecated. Replace with `read_if_exists`.")
|
|
56
|
+
read_if_exists(path)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.generate(current)
|
|
60
|
+
new(current.transform_values do |criteria|
|
|
61
|
+
criteria.transform_values { |floor| Floor.new(percent: floor.fetch(:percent), missed: floor.fetch(:missed)) }
|
|
62
|
+
end)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
attr_reader :entries
|
|
66
|
+
|
|
67
|
+
def initialize(entries)
|
|
68
|
+
@entries = entries
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def entry_for(project_filename)
|
|
72
|
+
entries[project_filename]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def floor_for(project_filename, criterion)
|
|
76
|
+
entries.dig(project_filename, criterion)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Which is also what exempts the pair from the per-file thresholds.
|
|
80
|
+
def covers?(project_filename, criterion)
|
|
81
|
+
!floor_for(project_filename, criterion).nil?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Floors only move in the tightening direction, each axis independently:
|
|
85
|
+
# the percent keeps its best, the missed count its lowest. Entries for
|
|
86
|
+
# files `current` does not carry are pruned; files `current` carries
|
|
87
|
+
# without an entry stay uncovered, so new code answers to the global
|
|
88
|
+
# standard rather than to a floor cut at whatever it launched with. A
|
|
89
|
+
# criterion the current run did not measure keeps its floor, and a newly
|
|
90
|
+
# measured criterion joins the entry, so a later `enable_coverage :branch`
|
|
91
|
+
# grandfathers the legacy files' branch state.
|
|
92
|
+
def ratchet(current)
|
|
93
|
+
buckets = {tightened: [], pruned: [], regressed: [], unchanged: []} #: Hash[Symbol, Array[String]]
|
|
94
|
+
ratcheted = entries.filter_map do |file, entry|
|
|
95
|
+
step = ratchet_entry(entry, current[file])
|
|
96
|
+
buckets.fetch(step.fetch(:bucket)) << file
|
|
97
|
+
merged = step[:entry]
|
|
98
|
+
[file, merged] if merged
|
|
99
|
+
end.to_h
|
|
100
|
+
|
|
101
|
+
Outcome.new(baseline: Baseline.new(ratcheted), tightened: buckets.fetch(:tightened),
|
|
102
|
+
pruned: buckets.fetch(:pruned), regressed: buckets.fetch(:regressed),
|
|
103
|
+
unchanged: buckets.fetch(:unchanged))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Sorted by path so a ratchet rewrite diffs as the set of floors that
|
|
107
|
+
# actually moved.
|
|
108
|
+
def to_yaml
|
|
109
|
+
document = entries.sort.to_h do |file, entry|
|
|
110
|
+
[file, entry.to_h { |criterion, floor| [CRITERIA.key(criterion), dump_floor(floor)] }]
|
|
111
|
+
end
|
|
112
|
+
HEADER + YAML.dump(document).delete_prefix("---\n")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# A nil current prunes the entry, which carries no replacement and so
|
|
118
|
+
# answers with the bucket alone. Otherwise every measured criterion
|
|
119
|
+
# tightens, keeping the ones this run did not measure.
|
|
120
|
+
def ratchet_entry(entry, current_entry)
|
|
121
|
+
return {bucket: :pruned} unless current_entry
|
|
122
|
+
|
|
123
|
+
merged = (entry.keys | current_entry.keys).to_h do |criterion|
|
|
124
|
+
[criterion, tighten(entry[criterion], current_entry[criterion])]
|
|
125
|
+
end
|
|
126
|
+
{bucket: bucket_for(entry, merged, current_entry), entry: merged}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The regressed bucket mirrors the violation rule exactly, so "below its
|
|
130
|
+
# floor" in the ratchet summary always means the exit check fails the file,
|
|
131
|
+
# never a percent drift the dampener tolerates.
|
|
132
|
+
def bucket_for(entry, merged, current_entry)
|
|
133
|
+
# Exact equality rather than numeric: tightening keeps the entry's own
|
|
134
|
+
# floor on a tie, so an entry that did not move is the very values it
|
|
135
|
+
# came in with.
|
|
136
|
+
return :tightened unless merged.eql?(entry)
|
|
137
|
+
|
|
138
|
+
regressed = entry.any? do |criterion, floor|
|
|
139
|
+
current = current_entry[criterion]
|
|
140
|
+
current && current.fetch(:percent) < floor.percent &&
|
|
141
|
+
(floor.missed.nil? || current.fetch(:missed) > floor.missed)
|
|
142
|
+
end
|
|
143
|
+
regressed ? :regressed : :unchanged
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Either side may be absent: a floor with no current measurement stays, a
|
|
147
|
+
# measurement with no floor becomes one.
|
|
148
|
+
def tighten(floor, current)
|
|
149
|
+
# The keys union in ratchet_entry guarantees at least one side is present,
|
|
150
|
+
# which the cast restates for the type checker.
|
|
151
|
+
return (_ = floor) unless current
|
|
152
|
+
|
|
153
|
+
current_floor = Floor.new(percent: current.fetch(:percent), missed: current.fetch(:missed))
|
|
154
|
+
return current_floor unless floor
|
|
155
|
+
|
|
156
|
+
Floor.new(
|
|
157
|
+
percent: [floor.percent, current_floor.percent].max,
|
|
158
|
+
missed: [floor.missed, current_floor.missed].compact.min
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def dump_floor(floor)
|
|
163
|
+
dumped = {"percent" => floor.percent} #: Hash[String, untyped]
|
|
164
|
+
dumped["missed"] = floor.missed unless floor.missed.nil?
|
|
165
|
+
dumped
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
require_relative "baseline/parser"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../git"
|
|
4
|
+
|
|
5
|
+
module SimpleCov
|
|
6
|
+
module CLI
|
|
7
|
+
module Affected
|
|
8
|
+
# The change as git sees it, as `{root:, changed:}`: the files that differ
|
|
9
|
+
# between the merge base of the given ref and the working tree, so commits
|
|
10
|
+
# that landed on the base after the branch point stay out while uncommitted
|
|
11
|
+
# work stays in, unioned with untracked files, since a brand-new spec exists
|
|
12
|
+
# in no diff. Everything runs against the repository toplevel rather than the
|
|
13
|
+
# cwd, so a subdirectory run selects over the whole change. NUL separation
|
|
14
|
+
# keeps exotic filenames literal instead of quoted.
|
|
15
|
+
module ChangedFiles
|
|
16
|
+
extend self
|
|
17
|
+
|
|
18
|
+
def call(base, stderr)
|
|
19
|
+
return report(stderr, "invalid base ref #{base.inspect}") if Git.option_like_ref?(base)
|
|
20
|
+
|
|
21
|
+
root = repository_toplevel(stderr)
|
|
22
|
+
return nil unless root
|
|
23
|
+
|
|
24
|
+
diff = list("diff", ["-C", root, "diff", "--name-only", "-z", "--merge-base", base, "--"], stderr)
|
|
25
|
+
return nil unless diff
|
|
26
|
+
|
|
27
|
+
untracked = list("ls-files", ["-C", root, "ls-files", "--others", "--exclude-standard", "-z"], stderr)
|
|
28
|
+
untracked && {root: root, changed: (diff + untracked).uniq}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# nil stdout from `Git.capture` means git never ran; a run that failed
|
|
32
|
+
# means the cwd is outside a working tree.
|
|
33
|
+
def repository_toplevel(stderr)
|
|
34
|
+
stdout, detail, success = Git.capture("rev-parse", "--show-toplevel")
|
|
35
|
+
return (_ = stdout).chomp if success
|
|
36
|
+
return report(stderr, "cannot run git (#{detail})") if stdout.nil?
|
|
37
|
+
|
|
38
|
+
report(stderr, "not inside a git working tree")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# git's own first stderr line is relayed on failure, so a bad ref or an old
|
|
42
|
+
# git names itself.
|
|
43
|
+
def list(command, argv, stderr)
|
|
44
|
+
stdout, detail, success = Git.capture(*argv)
|
|
45
|
+
return (_ = stdout).split("\0") if success
|
|
46
|
+
return report(stderr, "cannot run git (#{detail})") if stdout.nil?
|
|
47
|
+
|
|
48
|
+
report(stderr, "`git #{command}` failed: #{detail}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def report(stderr, message)
|
|
52
|
+
stderr.puts("simplecov affected: #{message}")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Affected
|
|
6
|
+
module Selection
|
|
7
|
+
TEST_FILE_NAME = /\A(?:test_.*|.*_(?:test|spec))\.rb\z/
|
|
8
|
+
|
|
9
|
+
extend self
|
|
10
|
+
|
|
11
|
+
def build(changed, document, contexts, opts, stderr, root:)
|
|
12
|
+
none = {} #: Hash[String, untyped]
|
|
13
|
+
coverage = document.fetch("coverage", none)
|
|
14
|
+
unless coverage.instance_of?(Hash)
|
|
15
|
+
return CoverageFile.report_invalid(stderr, "affected", opts.fetch(:input), '"coverage" must be an object')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
state = initial_state(changed, contexts, coverage, opts, stderr, root: root)
|
|
19
|
+
return nil unless changed.all? { |path| classify(path, state) }
|
|
20
|
+
|
|
21
|
+
{tests: state.fetch(:tests).uniq.sort, triggers: state.fetch(:triggers)}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initial_state(changed, contexts, coverage, opts, stderr, root:)
|
|
25
|
+
{
|
|
26
|
+
tests: [], triggers: [], changed: changed, contexts: contexts,
|
|
27
|
+
known: contexts.each_with_object(Set.new) { |id, set| set << test_file_of(id) },
|
|
28
|
+
index: CoverageFile.exact_index(coverage), root: root, opts: opts, stderr: stderr
|
|
29
|
+
} #: Hash[Symbol, untyped]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A recorded test file selects itself, a file the report covers selects the
|
|
33
|
+
# files of the tests touching it, a test-looking file the map has never seen
|
|
34
|
+
# always runs, and anything else is a trigger. Changed paths are exact
|
|
35
|
+
# root-relative names, so they resolve against the report exactly: a
|
|
36
|
+
# lookalike entry elsewhere must not stand in for a file it doesn't carry.
|
|
37
|
+
def classify(path, state)
|
|
38
|
+
entry = entry_for(path, state)
|
|
39
|
+
if state.fetch(:known).include?(path)
|
|
40
|
+
select_test_file(path, state)
|
|
41
|
+
elsif entry
|
|
42
|
+
select_covering(path, entry, state)
|
|
43
|
+
elsif TEST_FILE_NAME.match?(File.basename(path))
|
|
44
|
+
select_test_file(path, state, note_deleted: false)
|
|
45
|
+
else
|
|
46
|
+
note_untracked(path, state)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def note_untracked(path, state)
|
|
51
|
+
state.fetch(:triggers) << "#{path} changed but #{state.fetch(:opts).fetch(:input)} has no data for it"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# A changed test file runs, unless the change is its deletion: its tests are
|
|
55
|
+
# gone, which removes them from the answer without distrusting the map.
|
|
56
|
+
def select_test_file(path, state, note_deleted: true)
|
|
57
|
+
if on_disk?(path, state)
|
|
58
|
+
state.fetch(:tests) << path
|
|
59
|
+
elsif note_deleted
|
|
60
|
+
state.fetch(:stderr).puts("simplecov affected: skipping deleted test file #{path}")
|
|
61
|
+
end
|
|
62
|
+
path
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Existence judged at the repository root the paths are relative to, not the
|
|
66
|
+
# cwd, so a subdirectory run answers the same.
|
|
67
|
+
def on_disk?(path, state)
|
|
68
|
+
File.exist?(File.expand_path(path, state.fetch(:root)))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def entry_for(path, state)
|
|
72
|
+
state.fetch(:index)[File.expand_path(path, state.fetch(:root))] || state.fetch(:index)[path]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def select_covering(path, entry, state)
|
|
76
|
+
table = covering_table(path, entry, state)
|
|
77
|
+
table&.each_key { |index| select_context(state.fetch(:contexts).fetch(index), path, state) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def covering_table(path, entry, state)
|
|
81
|
+
unless entry.instance_of?(Hash)
|
|
82
|
+
return CoverageFile.report_invalid(state.fetch(:stderr), "affected", state.fetch(:opts).fetch(:input),
|
|
83
|
+
"entry for #{path} must be an object")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
raw = entry["contexts"] || {}
|
|
87
|
+
table = raw.instance_of?(Hash) && Tests.decode_table(raw, state.fetch(:contexts).size)
|
|
88
|
+
return table if table
|
|
89
|
+
|
|
90
|
+
CoverageFile.report_invalid(state.fetch(:stderr), "affected", state.fetch(:opts).fetch(:input),
|
|
91
|
+
"entry for #{path} carries a malformed \"contexts\" table")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Selected via its file, or a staleness trigger when it has no file to
|
|
95
|
+
# select: a locationless id, or a file that exists neither on disk nor in
|
|
96
|
+
# the change.
|
|
97
|
+
def select_context(id, path, state)
|
|
98
|
+
file = test_file_of(id)
|
|
99
|
+
if file.nil?
|
|
100
|
+
state.fetch(:triggers) << "recorded test #{id} touches #{path} but has no file location"
|
|
101
|
+
elsif on_disk?(file, state)
|
|
102
|
+
state.fetch(:tests) << file
|
|
103
|
+
elsif !state.fetch(:changed).include?(file)
|
|
104
|
+
state.fetch(:triggers) << "recorded test file #{file} no longer exists"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# "spec/a_spec.rb:12" and the :file granularity's bare "spec/a_spec.rb" both
|
|
109
|
+
# name a file, while a locationless fallback id names none.
|
|
110
|
+
def test_file_of(id)
|
|
111
|
+
base = id.sub(/:\d+\z/, "")
|
|
112
|
+
base if base.include?("/") || base.end_with?(".rb")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "command_helpers"
|
|
6
|
+
require_relative "tests"
|
|
7
|
+
require_relative "affected/changed_files"
|
|
8
|
+
require_relative "affected/selection"
|
|
9
|
+
|
|
10
|
+
module SimpleCov
|
|
11
|
+
module CLI
|
|
12
|
+
# `simplecov affected`: select the test files whose recorded tests touch the
|
|
13
|
+
# code changed since a git base ref, from a coverage.json written under
|
|
14
|
+
# `track_tests`. The change is everything between the merge base of `--base`
|
|
15
|
+
# and the working tree, plus untracked files.
|
|
16
|
+
#
|
|
17
|
+
# The set intersection is the easy half; the hard half is knowing when to
|
|
18
|
+
# distrust the map, because a test map is stale the moment something changes
|
|
19
|
+
# that no test mentions by name. Any changed file outside the tracked set
|
|
20
|
+
# fails open to the full suite, out loud: each trigger is named on stderr and
|
|
21
|
+
# stdout stays empty, which a bare runner reads as "run everything". Test
|
|
22
|
+
# files always select themselves, recorded or not.
|
|
23
|
+
module Affected
|
|
24
|
+
extend CommandHelpers
|
|
25
|
+
|
|
26
|
+
extend self
|
|
27
|
+
|
|
28
|
+
def run(args, stdout:, stderr:)
|
|
29
|
+
opts = parse(args)
|
|
30
|
+
issue = precheck(opts)
|
|
31
|
+
return error(stderr, issue) if issue
|
|
32
|
+
|
|
33
|
+
document = CoverageFile.load_document(opts.fetch(:input), command: "affected", stderr: stderr)
|
|
34
|
+
return 1 unless document
|
|
35
|
+
|
|
36
|
+
contexts = recorded_contexts(document, opts, stderr)
|
|
37
|
+
return 1 unless contexts
|
|
38
|
+
|
|
39
|
+
diffed = ChangedFiles.call(opts[:base] ||= Git.default_base, stderr)
|
|
40
|
+
return 1 unless diffed
|
|
41
|
+
|
|
42
|
+
respond(diffed, document, contexts, opts, stdout: stdout, stderr: stderr)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parse(args)
|
|
46
|
+
head, runner = split_runner(args)
|
|
47
|
+
opts, rest = parse_common(head, base: nil, run: runner) do |parser, into|
|
|
48
|
+
parser.on("--base REF") { |v| into[:base] = v }
|
|
49
|
+
end
|
|
50
|
+
opts[:rest] = rest
|
|
51
|
+
opts
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Everything after `--run` is the runner command, verbatim, so the command's
|
|
55
|
+
# own flags never collide with ours.
|
|
56
|
+
def split_runner(args)
|
|
57
|
+
index = args.index("--run")
|
|
58
|
+
return [args, nil] unless index
|
|
59
|
+
|
|
60
|
+
[args.take(index), args.drop(index + 1)]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A stray positional looks exactly like a forgotten `--base` ref, and
|
|
64
|
+
# silently dropping it would select tests for the wrong change.
|
|
65
|
+
def precheck(opts)
|
|
66
|
+
stray = opts.fetch(:rest).first
|
|
67
|
+
return "unexpected argument #{stray.inspect} (did you mean `--base #{stray}`?)" if stray
|
|
68
|
+
return "missing command after --run" if opts.fetch(:run) && opts.fetch(:run).empty?
|
|
69
|
+
|
|
70
|
+
"--run and --json cannot be combined" if opts.fetch(:run) && opts.fetch(:json)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def respond(diffed, document, contexts, opts, stdout:, stderr:)
|
|
74
|
+
opts[:root] = diffed.fetch(:root)
|
|
75
|
+
return no_changes(opts, stdout, stderr) if diffed.fetch(:changed).empty?
|
|
76
|
+
|
|
77
|
+
selection = Selection.build(diffed.fetch(:changed), document, contexts, opts, stderr, root: diffed.fetch(:root))
|
|
78
|
+
return 1 unless selection
|
|
79
|
+
|
|
80
|
+
deliver(selection, opts, stdout, stderr)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def no_changes(opts, stdout, stderr)
|
|
84
|
+
stderr.puts("simplecov affected: no changes against #{opts.fetch(:base)}")
|
|
85
|
+
empty = {tests: [], triggers: []} #: Hash[Symbol, Array[String]]
|
|
86
|
+
emit_json(empty, false, stdout) if opts.fetch(:json)
|
|
87
|
+
0
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def deliver(selection, opts, stdout, stderr)
|
|
91
|
+
full = !selection.fetch(:triggers).empty?
|
|
92
|
+
selection.fetch(:triggers).each { |trigger| stderr.puts("simplecov affected: #{trigger}") }
|
|
93
|
+
stderr.puts("simplecov affected: falling back to the full suite") if full
|
|
94
|
+
return emit_json(selection, full, stdout) if opts.fetch(:json)
|
|
95
|
+
return execute(selection, full, opts, stderr) if opts.fetch(:run)
|
|
96
|
+
|
|
97
|
+
emit_text(selection, full, stdout, stderr)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def emit_json(selection, full, stdout)
|
|
101
|
+
none = [] #: Array[String]
|
|
102
|
+
stdout.puts(JSON.pretty_generate("full_suite" => full, "triggers" => selection.fetch(:triggers),
|
|
103
|
+
"tests" => full ? none : selection.fetch(:tests)))
|
|
104
|
+
0
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Stdout carries the selected files and nothing else, so the list can feed a
|
|
108
|
+
# runner directly, and a full-suite fallback prints nothing, which
|
|
109
|
+
# `rspec $(simplecov affected)` reads as "run everything".
|
|
110
|
+
def emit_text(selection, full, stdout, stderr)
|
|
111
|
+
return 0 if full
|
|
112
|
+
|
|
113
|
+
tests = selection.fetch(:tests)
|
|
114
|
+
if tests.empty?
|
|
115
|
+
note_untouched(stderr)
|
|
116
|
+
else
|
|
117
|
+
tests.each { |file| stdout.puts(file) }
|
|
118
|
+
0
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def note_untouched(stderr)
|
|
123
|
+
stderr.puts("simplecov affected: no recorded test touches the changed code")
|
|
124
|
+
0
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def execute(selection, full, opts, stderr)
|
|
128
|
+
return run_command(opts.fetch(:run), opts.fetch(:root), stderr) if full
|
|
129
|
+
return note_untouched(stderr) if selection.fetch(:tests).empty?
|
|
130
|
+
|
|
131
|
+
count = selection.fetch(:tests).size
|
|
132
|
+
stderr.puts("simplecov affected: running #{count} test file#{"s" unless count.eql?(1)}")
|
|
133
|
+
run_command(opts.fetch(:run) + selection.fetch(:tests), opts.fetch(:root), stderr)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The selection's paths are relative to the repository root, so the runner
|
|
137
|
+
# starts there. The command is named explicitly in the failure because not
|
|
138
|
+
# every engine's exception message carries it.
|
|
139
|
+
def run_command(command, root, stderr)
|
|
140
|
+
_, status = Process.wait2(spawn(*command, chdir: root))
|
|
141
|
+
status.exitstatus || 1
|
|
142
|
+
rescue SystemCallError => e
|
|
143
|
+
stderr.puts("simplecov affected: cannot run #{command.first.inspect} (#{e})")
|
|
144
|
+
127
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|