simplecov 1.1.1 → 1.2.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 (194) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +148 -5
  3. data/exe/simplecov +0 -3
  4. data/lib/minitest/simplecov_plugin.rb +12 -9
  5. data/lib/simplecov/atomic_file.rb +16 -19
  6. data/lib/simplecov/autostart.rb +5 -7
  7. data/lib/simplecov/baseline/parser.rb +58 -0
  8. data/lib/simplecov/baseline.rb +170 -0
  9. data/lib/simplecov/cli/affected/changed_files.rb +57 -0
  10. data/lib/simplecov/cli/affected/selection.rb +117 -0
  11. data/lib/simplecov/cli/affected.rb +148 -0
  12. data/lib/simplecov/cli/badge/svg.rb +70 -0
  13. data/lib/simplecov/cli/badge.rb +62 -0
  14. data/lib/simplecov/cli/clean.rb +23 -24
  15. data/lib/simplecov/cli/command_helpers.rb +55 -19
  16. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  17. data/lib/simplecov/cli/completions.rb +83 -0
  18. data/lib/simplecov/cli/coverage.rb +25 -28
  19. data/lib/simplecov/cli/coverage_file.rb +58 -14
  20. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  21. data/lib/simplecov/cli/dead_code.rb +139 -0
  22. data/lib/simplecov/cli/diff/output.rb +45 -0
  23. data/lib/simplecov/cli/diff.rb +35 -67
  24. data/lib/simplecov/cli/dotfile.rb +83 -38
  25. data/lib/simplecov/cli/git.rb +41 -0
  26. data/lib/simplecov/cli/history/output.rb +135 -0
  27. data/lib/simplecov/cli/history.rb +67 -0
  28. data/lib/simplecov/cli/merge.rb +34 -39
  29. data/lib/simplecov/cli/open.rb +12 -15
  30. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  31. data/lib/simplecov/cli/patch/output.rb +135 -0
  32. data/lib/simplecov/cli/patch.rb +169 -0
  33. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  34. data/lib/simplecov/cli/ratchet.rb +83 -0
  35. data/lib/simplecov/cli/report.rb +18 -24
  36. data/lib/simplecov/cli/run.rb +8 -9
  37. data/lib/simplecov/cli/serve/report_preparer.rb +7 -6
  38. data/lib/simplecov/cli/serve/static_file_handler.rb +62 -56
  39. data/lib/simplecov/cli/serve.rb +27 -24
  40. data/lib/simplecov/cli/show/annotator.rb +87 -0
  41. data/lib/simplecov/cli/show/sweep.rb +36 -0
  42. data/lib/simplecov/cli/show.rb +133 -0
  43. data/lib/simplecov/cli/status/facts.rb +87 -0
  44. data/lib/simplecov/cli/status.rb +93 -0
  45. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  46. data/lib/simplecov/cli/tests.rb +148 -0
  47. data/lib/simplecov/cli/uncovered/misses.rb +37 -0
  48. data/lib/simplecov/cli/uncovered.rb +70 -32
  49. data/lib/simplecov/cli/usage.rb +152 -0
  50. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  51. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  52. data/lib/simplecov/cli/watch/poller.rb +45 -0
  53. data/lib/simplecov/cli/watch/session.rb +134 -0
  54. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  55. data/lib/simplecov/cli/watch.rb +85 -0
  56. data/lib/simplecov/cli.rb +43 -92
  57. data/lib/simplecov/color.rb +19 -37
  58. data/lib/simplecov/combine/branches_combiner.rb +7 -31
  59. data/lib/simplecov/combine/coverage_accumulator.rb +49 -142
  60. data/lib/simplecov/combine/identity_interner.rb +10 -13
  61. data/lib/simplecov/combine/interned_counts.rb +7 -9
  62. data/lib/simplecov/combine/lines_combiner.rb +20 -44
  63. data/lib/simplecov/combine/methods_combiner.rb +9 -35
  64. data/lib/simplecov/combine/results_combiner.rb +4 -21
  65. data/lib/simplecov/combine.rb +0 -6
  66. data/lib/simplecov/command_guesser.rb +20 -34
  67. data/lib/simplecov/configuration/baseline.rb +31 -0
  68. data/lib/simplecov/configuration/coverage.rb +91 -79
  69. data/lib/simplecov/configuration/coverage_criteria.rb +43 -54
  70. data/lib/simplecov/configuration/deprecations.rb +21 -0
  71. data/lib/simplecov/configuration/eval_coverage.rb +6 -10
  72. data/lib/simplecov/configuration/filters.rb +40 -73
  73. data/lib/simplecov/configuration/formatting.rb +74 -83
  74. data/lib/simplecov/configuration/groups.rb +19 -13
  75. data/lib/simplecov/configuration/history.rb +44 -0
  76. data/lib/simplecov/configuration/ignored_entries.rb +31 -22
  77. data/lib/simplecov/configuration/merging.rb +94 -62
  78. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  79. data/lib/simplecov/configuration/production.rb +23 -0
  80. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  81. data/lib/simplecov/configuration/thresholds.rb +33 -62
  82. data/lib/simplecov/configuration/view_coverage.rb +38 -0
  83. data/lib/simplecov/configuration.rb +70 -76
  84. data/lib/simplecov/configuration_error.rb +5 -0
  85. data/lib/simplecov/context_map/union.rb +81 -0
  86. data/lib/simplecov/context_map.rb +187 -0
  87. data/lib/simplecov/coverage_json.rb +2 -5
  88. data/lib/simplecov/coverage_statistics.rb +14 -24
  89. data/lib/simplecov/coverage_violations.rb +157 -64
  90. data/lib/simplecov/current_run.rb +61 -0
  91. data/lib/simplecov/defaults.rb +4 -23
  92. data/lib/simplecov/deprecation.rb +45 -27
  93. data/lib/simplecov/directive.rb +46 -62
  94. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  95. data/lib/simplecov/exit_codes/check.rb +17 -11
  96. data/lib/simplecov/exit_codes/exit_code_handling.rb +21 -8
  97. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +7 -10
  98. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  99. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  100. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +7 -12
  101. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +11 -10
  102. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +7 -9
  103. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +16 -17
  104. data/lib/simplecov/exit_codes.rb +16 -10
  105. data/lib/simplecov/exit_handling.rb +94 -59
  106. data/lib/simplecov/file_list.rb +20 -51
  107. data/lib/simplecov/filter.rb +43 -66
  108. data/lib/simplecov/formatter/base.rb +37 -55
  109. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  110. data/lib/simplecov/formatter/coverage_json_writer.rb +29 -31
  111. data/lib/simplecov/formatter/html_formatter/public/index.html +15 -9
  112. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +74 -17
  113. data/lib/simplecov/formatter/html_formatter.rb +24 -41
  114. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +50 -16
  115. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  116. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +43 -29
  117. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +14 -10
  118. data/lib/simplecov/formatter/json_formatter.rb +7 -13
  119. data/lib/simplecov/formatter/multi_formatter.rb +5 -11
  120. data/lib/simplecov/formatter/simple_formatter.rb +2 -8
  121. data/lib/simplecov/formatter.rb +14 -10
  122. data/lib/simplecov/group_names.rb +13 -12
  123. data/lib/simplecov/history.rb +115 -0
  124. data/lib/simplecov/last_run.rb +6 -9
  125. data/lib/simplecov/lines_classifier.rb +31 -16
  126. data/lib/simplecov/load_global_config.rb +7 -7
  127. data/lib/simplecov/parallel_adapters/base.rb +13 -41
  128. data/lib/simplecov/parallel_adapters/generic.rb +7 -19
  129. data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
  130. data/lib/simplecov/parallel_adapters.rb +24 -55
  131. data/lib/simplecov/parallel_coordination.rb +39 -49
  132. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  133. data/lib/simplecov/parallel_result_merger.rb +67 -126
  134. data/lib/simplecov/process.rb +14 -32
  135. data/lib/simplecov/production/error.rb +11 -0
  136. data/lib/simplecov/production/file_sink.rb +130 -0
  137. data/lib/simplecov/production.rb +249 -0
  138. data/lib/simplecov/profiles/rails.rb +1 -12
  139. data/lib/simplecov/profiles/root_filter.rb +0 -4
  140. data/lib/simplecov/profiles/strict.rb +9 -19
  141. data/lib/simplecov/profiles.rb +11 -33
  142. data/lib/simplecov/report_deferral.rb +11 -15
  143. data/lib/simplecov/report_stamp.rb +6 -9
  144. data/lib/simplecov/result/filter_config.rb +19 -0
  145. data/lib/simplecov/result/missing_source_files_reporter.rb +8 -9
  146. data/lib/simplecov/result/serialization.rb +43 -0
  147. data/lib/simplecov/result/source_file_builder.rb +7 -12
  148. data/lib/simplecov/result.rb +68 -117
  149. data/lib/simplecov/result_adapter.rb +56 -68
  150. data/lib/simplecov/result_merger/contexts.rb +22 -0
  151. data/lib/simplecov/result_merger/legacy_format_adapter.rb +4 -8
  152. data/lib/simplecov/result_merger/resultset_file.rb +20 -27
  153. data/lib/simplecov/result_merger/resultset_run_identity.rb +23 -25
  154. data/lib/simplecov/result_merger/resultset_store.rb +23 -14
  155. data/lib/simplecov/result_merger/unloaded_files.rb +24 -32
  156. data/lib/simplecov/result_merger.rb +59 -58
  157. data/lib/simplecov/result_processing.rb +74 -119
  158. data/lib/simplecov/run_identity.rb +16 -18
  159. data/lib/simplecov/simulate_coverage.rb +34 -50
  160. data/lib/simplecov/source_file/branch.rb +8 -33
  161. data/lib/simplecov/source_file/branch_builder.rb +28 -47
  162. data/lib/simplecov/source_file/builder_context.rb +6 -13
  163. data/lib/simplecov/source_file/line.rb +13 -33
  164. data/lib/simplecov/source_file/line_builder.rb +9 -15
  165. data/lib/simplecov/source_file/method.rb +3 -7
  166. data/lib/simplecov/source_file/method_builder.rb +6 -15
  167. data/lib/simplecov/source_file/ruby_data_parser.rb +59 -66
  168. data/lib/simplecov/source_file/skip_chunks.rb +19 -28
  169. data/lib/simplecov/source_file/source_loader.rb +45 -35
  170. data/lib/simplecov/source_file/statistics.rb +10 -9
  171. data/lib/simplecov/source_file.rb +21 -51
  172. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +63 -114
  173. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
  174. data/lib/simplecov/static_coverage_extractor/method_collector.rb +11 -24
  175. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +15 -24
  176. data/lib/simplecov/static_coverage_extractor/value_position.rb +25 -38
  177. data/lib/simplecov/static_coverage_extractor/visitor.rb +47 -64
  178. data/lib/simplecov/static_coverage_extractor.rb +46 -62
  179. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  180. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  181. data/lib/simplecov/test_tracker/delta.rb +69 -0
  182. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  183. data/lib/simplecov/test_tracker.rb +162 -0
  184. data/lib/simplecov/unloaded_file_injector.rb +18 -31
  185. data/lib/simplecov/useless_results_remover.rb +7 -6
  186. data/lib/simplecov/version.rb +1 -1
  187. data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
  188. data/lib/simplecov/view_coverage.rb +55 -0
  189. data/lib/simplecov-html.rb +1 -0
  190. data/lib/simplecov.rb +57 -113
  191. data/lib/simplecov_json_formatter.rb +1 -0
  192. data/man/simplecov.1 +349 -0
  193. data/sig/simplecov.rbs +523 -595
  194. metadata +72 -5
@@ -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
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Badge
6
+ # Renders the badge SVG in shields.io's flat style: 20px tall with 3px
7
+ # rounded corners, the label on #555 and the value on its ladder color, each
8
+ # text drawn twice for the drop shadow at 10x scale. `textLength` pins the
9
+ # layout, so the estimated segment widths never let glyphs spill.
10
+ module Svg
11
+ extend self
12
+
13
+ COLORS = [[90, "#4c1"], [80, "#97ca00"], [70, "#a4a61d"], [60, "#dfb317"], [50, "#fe7d37"]].freeze
14
+
15
+ def color(percent)
16
+ rung = COLORS.detect { |floor, _color| percent >= floor }
17
+ rung ? rung.fetch(1) : "#e05d44"
18
+ end
19
+
20
+ def render(label:, percent:)
21
+ value = format("%.2f%%", percent)
22
+ document(label: escape(label), value: value, fill: color(percent),
23
+ geo: geometry(width(label), width(value)))
24
+ end
25
+
26
+ # Estimates 11px Verdana; `textLength` absorbs the error.
27
+ def width(text)
28
+ (7 * text.length) + 10
29
+ end
30
+
31
+ # The scale(.1) trick draws text at 10x and shrinks it, so the text
32
+ # coordinates live in a 10x space: centers at 10 * (offset + width / 2) and
33
+ # textLength spanning the width less padding.
34
+ def geometry(label_width, value_width)
35
+ {label_width: label_width, value_width: value_width, total: label_width + value_width,
36
+ label_x: 5 * label_width, value_x: (10 * label_width) + (5 * value_width),
37
+ label_span: 10 * (label_width - 10), value_span: 10 * (value_width - 10)}
38
+ end
39
+
40
+ def escape(text)
41
+ text.gsub(/[&<>"]/, "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;")
42
+ end
43
+
44
+ def document(label:, value:, fill:, geo:)
45
+ <<~SVG
46
+ <svg xmlns="http://www.w3.org/2000/svg" width="#{geo.fetch(:total)}" height="20" role="img" aria-label="#{label}: #{value}">
47
+ <title>#{label}: #{value}</title>
48
+ <linearGradient id="s" x2="0" y2="100%">
49
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
50
+ <stop offset="1" stop-opacity=".1"/>
51
+ </linearGradient>
52
+ <clipPath id="r"><rect width="#{geo.fetch(:total)}" height="20" rx="3" fill="#fff"/></clipPath>
53
+ <g clip-path="url(#r)">
54
+ <rect width="#{geo.fetch(:label_width)}" height="20" fill="#555"/>
55
+ <rect x="#{geo.fetch(:label_width)}" width="#{geo.fetch(:value_width)}" height="20" fill="#{fill}"/>
56
+ <rect width="#{geo.fetch(:total)}" height="20" fill="url(#s)"/>
57
+ </g>
58
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
59
+ <text aria-hidden="true" x="#{geo.fetch(:label_x)}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="#{geo.fetch(:label_span)}">#{label}</text>
60
+ <text x="#{geo.fetch(:label_x)}" y="140" transform="scale(.1)" fill="#fff" textLength="#{geo.fetch(:label_span)}">#{label}</text>
61
+ <text aria-hidden="true" x="#{geo.fetch(:value_x)}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="#{geo.fetch(:value_span)}">#{value}</text>
62
+ <text x="#{geo.fetch(:value_x)}" y="140" transform="scale(.1)" fill="#fff" textLength="#{geo.fetch(:value_span)}">#{value}</text>
63
+ </g>
64
+ </svg>
65
+ SVG
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "command_helpers"
5
+ require_relative "coverage_file"
6
+ require_relative "badge/svg"
7
+
8
+ module SimpleCov
9
+ module CLI
10
+ module Badge
11
+ extend CommandHelpers
12
+
13
+ extend self
14
+
15
+ TOTAL_KEYS = {line: "lines", branch: "branches", method: "methods"}.freeze
16
+
17
+ def run(args, stdout:, stderr:)
18
+ opts = parse(args)
19
+ key = TOTAL_KEYS[opts.fetch(:criterion)]
20
+ unless key
21
+ return error(stderr,
22
+ "unknown --criterion #{opts.fetch(:criterion).inspect} (expected line, branch, or method)")
23
+ end
24
+
25
+ percent = percent_for(opts, key, stderr)
26
+ return 1 unless percent
27
+
28
+ label = opts.fetch(:label) || "#{opts.fetch(:criterion)} coverage"
29
+ emit(Svg.render(label: label, percent: percent), opts, stdout)
30
+ end
31
+
32
+ def parse(args)
33
+ opts = {input: CLI.default_input, criterion: :line,
34
+ label: nil, output: nil} #: Hash[Symbol, untyped]
35
+ build_parser do |parser|
36
+ parser.on("--input PATH") { |v| opts[:input] = v }
37
+ parser.on("--output PATH") { |v| opts[:output] = v }
38
+ parser.on("--criterion C") { |v| opts[:criterion] = v.to_sym }
39
+ parser.on("--label TEXT") { |v| opts[:label] = v }
40
+ end.parse(args)
41
+ opts
42
+ end
43
+
44
+ def percent_for(opts, key, stderr)
45
+ document = CoverageFile.load_document(opts.fetch(:input), command: "badge", stderr: stderr)
46
+ return nil unless document
47
+
48
+ totals = document["total"]
49
+ percent = totals.dig(key, "percent") if totals.is_a?(Hash)
50
+ return percent if percent.is_a?(Numeric)
51
+
52
+ error_nil(stderr, "no #{opts.fetch(:criterion)} totals in #{opts.fetch(:input)} " \
53
+ "(was the run measuring #{opts.fetch(:criterion)} coverage?)")
54
+ end
55
+
56
+ def emit(svg, opts, stdout)
57
+ opts.fetch(:output) ? File.write(opts.fetch(:output), svg) : stdout.puts(svg)
58
+ 0
59
+ end
60
+ end
61
+ end
62
+ end
@@ -1,22 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
3
4
  require "optparse"
4
5
  require_relative "command_helpers"
5
6
 
6
7
  module SimpleCov
7
8
  module CLI
8
- # `simplecov clean [--dry-run]` remove the coverage report
9
- # directory (or whatever `SimpleCov.coverage_dir` resolves to). The
10
- # `--dry-run` flag prints what would be deleted without touching
11
- # disk, for when you're not sure what's in there.
9
+ # `simplecov clean [--dry-run]`: remove the coverage report directory.
10
+ # `--dry-run` prints what would be deleted without touching disk.
12
11
  module Clean
13
12
  extend CommandHelpers
14
13
 
15
- module_function
14
+ extend self
16
15
 
17
16
  def run(args, stdout:, stderr:, **)
18
17
  opts = parse(args)
19
- dir = SimpleCov::CLI.coverage_dir
18
+ dir = CLI.coverage_dir
20
19
  return error(stderr, "refusing to remove unsafe coverage directory #{dir.inspect}") unless safe_to_remove?(dir)
21
20
  return announce(stdout, opts, "#{dir} doesn't exist; nothing to do") || 0 unless File.directory?(dir)
22
21
 
@@ -25,45 +24,45 @@ module SimpleCov
25
24
  end
26
25
 
27
26
  def sweep(dir, opts, stdout)
28
- if opts[:dry_run]
27
+ if opts.fetch(:dry_run)
29
28
  announce(stdout, opts, "would remove #{dir} (#{entry_count(dir)} entries)")
30
29
  else
31
- require "fileutils"
32
30
  FileUtils.rm_rf(dir)
33
31
  announce(stdout, opts, "removed #{dir}")
34
32
  end
35
33
  end
36
34
 
37
- # FNM_DOTMATCH so the dotfiles rm_rf will actually delete
38
- # (.resultset.json, .last_run.json) count too; the glob still
39
- # yields the directory's own "." entry, which rm_rf does not
35
+ # FNM_DOTMATCH so the dotfiles rm_rf will actually delete count too. The
36
+ # glob still yields the directory's own "." entry, which rm_rf does not
40
37
  # remove separately.
41
38
  def entry_count(dir)
42
39
  Dir.glob("#{dir}/**/*", File::FNM_DOTMATCH).count { |path| !path.end_with?("/.") }
43
40
  end
44
41
 
45
42
  def announce(stdout, opts, message)
46
- stdout.puts("simplecov #{command_name}: #{message}") unless opts[:quiet]
43
+ stdout.puts("simplecov #{command_name}: #{message}") unless opts.fetch(:quiet)
47
44
  end
48
45
 
46
+ # A filesystem root is its own dirname, on every platform and every Windows
47
+ # drive. The descendant check only protects roots that happen to contain the
48
+ # cwd, which misses other drives, so roots are refused outright.
49
49
  def safe_to_remove?(dir)
50
50
  target = canonical_path(dir)
51
- # A filesystem root is its own dirname ("/" everywhere, drive
52
- # roots like "D:/" on Windows). The descendant check below only
53
- # protects roots that happen to contain the cwd or project root,
54
- # which misses other drives, so refuse roots outright.
55
- return false if File.dirname(target) == target
51
+ return false if File.dirname(target).eql?(target)
56
52
 
57
- protected_paths.none? { |path| path == target || descendant_of?(path, target) }
53
+ protected_paths.none? { |path| path.eql?(target) || descendant_of?(path, target) }
58
54
  end
59
55
 
56
+ # The working directory, which a coverage directory must not be or contain.
57
+ # The `.simplecov` project root needs no entry of its own: `Dotfile.find`
58
+ # walks up from the working directory, so any target containing that root
59
+ # contains the working directory too.
60
60
  def protected_paths
61
- project_root = SimpleCov::CLI::Dotfile.find
62
- [Dir.pwd, (File.dirname(project_root) if project_root)].compact.map { |path| canonical_path(path) }.uniq
61
+ [canonical_path(Dir.pwd)]
63
62
  end
64
63
 
65
- # Canonical paths only carry a trailing separator on a filesystem
66
- # root, and roots are refused before this check runs.
64
+ # Canonical paths only carry a trailing separator on a filesystem root, and
65
+ # roots are refused before this check runs.
67
66
  def descendant_of?(path, possible_ancestor)
68
67
  path.start_with?("#{possible_ancestor}#{File::SEPARATOR}")
69
68
  end
@@ -76,9 +75,9 @@ module SimpleCov
76
75
 
77
76
  def parse(args)
78
77
  opts = {dry_run: false, quiet: false}
79
- OptionParser.new do |o|
78
+ build_parser do |o|
80
79
  o.on("--dry-run") { opts[:dry_run] = true }
81
- o.on("-q", "--quiet") { opts[:quiet] = true }
80
+ quiet_option(o, opts)
82
81
  end.parse(args)
83
82
  opts
84
83
  end
@@ -4,19 +4,21 @@ require "optparse"
4
4
 
5
5
  module SimpleCov
6
6
  module CLI
7
- # Plumbing the subcommands repeat, which their modules `extend`: the
8
- # standard option trio the read-only commands share, the parse
9
- # scaffold around it, the one-line error helper (prefixed with the
10
- # command's own name), and the human-readable stats row `coverage`
11
- # and `report` both print.
12
7
  module CommandHelpers
13
8
  STATS_ROW_FORMAT = " %<label>-7s %<pct>s (%<covered>d / %<total>d)"
14
9
 
15
- # "simplecov merge: ..." derived from the extending module's
16
- # name. The self cast collapses the mixin's view of `self` to the
17
- # Module that extends it, which is what carries `name`.
10
+ # Raised by the shared --help switch; `CLI.dispatch` turns it into the
11
+ # command's own slice of the usage text. The explicit handler also overrides
12
+ # optparse's built-in --help, which would print a bare option summary and
13
+ # exit the process outright from inside the parser.
14
+ class HelpRequested < StandardError; end
15
+
18
16
  def command_name
19
- (_ = self).name.split("::").last.downcase
17
+ name.split("::").last.downcase
18
+ end
19
+
20
+ def one?(count)
21
+ (count - 1).zero?
20
22
  end
21
23
 
22
24
  def error(stderr, message)
@@ -24,23 +26,57 @@ module SimpleCov
24
26
  1
25
27
  end
26
28
 
27
- # The --input / --json / --no-color trio every read-only
28
- # subcommand accepts.
29
+ # `error` returns 1 for an exit status; query helpers need nil so their
30
+ # callers can tell "reported" from a real answer.
31
+ def error_nil(stderr, message)
32
+ error(stderr, message)
33
+ nil
34
+ end
35
+
36
+ def recorded_contexts(document, opts, stderr)
37
+ contexts = document["contexts"]
38
+ if contexts.nil?
39
+ return error_nil(stderr, "no test contexts recorded in #{opts.fetch(:input)}. Enable `track_tests` in " \
40
+ "your `SimpleCov.start` block and rerun the suite to record them")
41
+ end
42
+ return contexts if contexts.instance_of?(Array) && contexts.all?(String)
43
+
44
+ CoverageFile.report_invalid(stderr, command_name, opts.fetch(:input), '"contexts" must be an array of strings')
45
+ end
46
+
29
47
  def common_options(parser, opts)
30
48
  parser.on("--input PATH") { |v| opts[:input] = v }
31
- parser.on("--json") { opts[:json] = true }
32
- parser.on("--no-color") { opts[:no_color] = true }
49
+ parser.on("--json") { opts[:json] = true }
50
+ parser.on("--no-color") { opts[:no_color] = true }
51
+ end
52
+
53
+ def build_parser
54
+ OptionParser.new do |parser|
55
+ yield parser if block_given?
56
+ on_help(parser)
57
+ end
58
+ end
59
+
60
+ # mutant:disable
61
+ # OptionParser answers `-q` as an unambiguous abbreviation of `--quiet`
62
+ # whether or not the short form is declared.
63
+ def quiet_option(parser, opts)
64
+ parser.on("-q", "--quiet") { opts[:quiet] = true }
65
+ end
66
+
67
+ # mutant:disable — optparse matches an abbreviated `-h` against
68
+ # `--help` whether or not the short form is declared, so dropping
69
+ # it changes nothing an example could see. Declared anyway, so the
70
+ # flag appears in the usage text.
71
+ def on_help(parser)
72
+ parser.on("--help", "-h") { raise HelpRequested }
33
73
  end
34
74
 
35
- # The parse scaffold every read-only subcommand repeats: seed the
36
- # shared defaults (plus the command's own), wire up the common
37
- # option trio, yield the parser for command-specific flags, and
38
- # return the parsed opts alongside the positional arguments.
39
75
  def parse_common(args, defaults = {})
40
- opts = {input: SimpleCov::CLI.default_input, json: false, no_color: false} #: Hash[Symbol, untyped]
76
+ opts = {input: CLI.default_input, json: false, no_color: false} #: Hash[Symbol, untyped]
41
77
  opts.merge!(defaults)
42
78
  rest =
43
- OptionParser.new do |parser|
79
+ build_parser do |parser|
44
80
  common_options(parser, opts)
45
81
  yield parser, opts if block_given?
46
82
  end.parse(args)