simplecov 1.1.1 → 1.3.2

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 (202) 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/runner.rb +38 -0
  11. data/lib/simplecov/cli/affected/selection.rb +117 -0
  12. data/lib/simplecov/cli/affected.rb +147 -0
  13. data/lib/simplecov/cli/annotations.rb +166 -0
  14. data/lib/simplecov/cli/badge/svg.rb +70 -0
  15. data/lib/simplecov/cli/badge.rb +62 -0
  16. data/lib/simplecov/cli/clean.rb +25 -25
  17. data/lib/simplecov/cli/command_helpers.rb +55 -19
  18. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  19. data/lib/simplecov/cli/completions.rb +83 -0
  20. data/lib/simplecov/cli/coverage.rb +25 -28
  21. data/lib/simplecov/cli/coverage_file.rb +59 -14
  22. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  23. data/lib/simplecov/cli/dead_code.rb +139 -0
  24. data/lib/simplecov/cli/diff/output.rb +45 -0
  25. data/lib/simplecov/cli/diff.rb +35 -67
  26. data/lib/simplecov/cli/dotfile.rb +83 -38
  27. data/lib/simplecov/cli/git.rb +41 -0
  28. data/lib/simplecov/cli/history/output.rb +135 -0
  29. data/lib/simplecov/cli/history.rb +67 -0
  30. data/lib/simplecov/cli/merge.rb +34 -39
  31. data/lib/simplecov/cli/open.rb +12 -15
  32. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  33. data/lib/simplecov/cli/patch/output.rb +151 -0
  34. data/lib/simplecov/cli/patch.rb +175 -0
  35. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  36. data/lib/simplecov/cli/ratchet.rb +83 -0
  37. data/lib/simplecov/cli/real_path.rb +41 -0
  38. data/lib/simplecov/cli/report.rb +18 -24
  39. data/lib/simplecov/cli/run.rb +8 -9
  40. data/lib/simplecov/cli/serve/report_preparer.rb +7 -6
  41. data/lib/simplecov/cli/serve/static_file_handler.rb +66 -58
  42. data/lib/simplecov/cli/serve.rb +27 -24
  43. data/lib/simplecov/cli/show/annotator.rb +87 -0
  44. data/lib/simplecov/cli/show/sweep.rb +36 -0
  45. data/lib/simplecov/cli/show.rb +133 -0
  46. data/lib/simplecov/cli/status/facts.rb +87 -0
  47. data/lib/simplecov/cli/status.rb +93 -0
  48. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  49. data/lib/simplecov/cli/tests.rb +148 -0
  50. data/lib/simplecov/cli/uncovered/misses.rb +33 -0
  51. data/lib/simplecov/cli/uncovered.rb +58 -34
  52. data/lib/simplecov/cli/usage.rb +153 -0
  53. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  54. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  55. data/lib/simplecov/cli/watch/poller.rb +45 -0
  56. data/lib/simplecov/cli/watch/session.rb +134 -0
  57. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  58. data/lib/simplecov/cli/watch.rb +85 -0
  59. data/lib/simplecov/cli.rb +44 -92
  60. data/lib/simplecov/color.rb +19 -37
  61. data/lib/simplecov/combine/branches_combiner.rb +7 -31
  62. data/lib/simplecov/combine/coverage_accumulator.rb +49 -142
  63. data/lib/simplecov/combine/identity_interner.rb +10 -13
  64. data/lib/simplecov/combine/interned_counts.rb +7 -9
  65. data/lib/simplecov/combine/lines_combiner.rb +20 -44
  66. data/lib/simplecov/combine/methods_combiner.rb +9 -35
  67. data/lib/simplecov/combine/results_combiner.rb +4 -21
  68. data/lib/simplecov/combine.rb +0 -6
  69. data/lib/simplecov/command_guesser.rb +20 -34
  70. data/lib/simplecov/configuration/baseline.rb +31 -0
  71. data/lib/simplecov/configuration/coverage.rb +91 -79
  72. data/lib/simplecov/configuration/coverage_criteria.rb +43 -54
  73. data/lib/simplecov/configuration/deprecations.rb +21 -0
  74. data/lib/simplecov/configuration/eval_coverage.rb +7 -11
  75. data/lib/simplecov/configuration/filters.rb +40 -73
  76. data/lib/simplecov/configuration/formatting.rb +74 -83
  77. data/lib/simplecov/configuration/groups.rb +19 -13
  78. data/lib/simplecov/configuration/history.rb +44 -0
  79. data/lib/simplecov/configuration/ignored_entries.rb +31 -22
  80. data/lib/simplecov/configuration/merging.rb +94 -62
  81. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  82. data/lib/simplecov/configuration/production.rb +23 -0
  83. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  84. data/lib/simplecov/configuration/thresholds.rb +33 -62
  85. data/lib/simplecov/configuration/view_coverage.rb +37 -0
  86. data/lib/simplecov/configuration.rb +70 -76
  87. data/lib/simplecov/configuration_error.rb +5 -0
  88. data/lib/simplecov/context_map/union.rb +81 -0
  89. data/lib/simplecov/context_map.rb +187 -0
  90. data/lib/simplecov/coverage_json.rb +2 -5
  91. data/lib/simplecov/coverage_statistics.rb +14 -24
  92. data/lib/simplecov/coverage_violations.rb +160 -65
  93. data/lib/simplecov/current_run.rb +61 -0
  94. data/lib/simplecov/defaults.rb +4 -23
  95. data/lib/simplecov/deprecation.rb +45 -27
  96. data/lib/simplecov/directive/erb.rb +44 -0
  97. data/lib/simplecov/directive/haml.rb +33 -0
  98. data/lib/simplecov/directive/indented_template.rb +37 -0
  99. data/lib/simplecov/directive/slim.rb +35 -0
  100. data/lib/simplecov/directive/template.rb +29 -0
  101. data/lib/simplecov/directive.rb +47 -62
  102. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  103. data/lib/simplecov/exit_codes/check.rb +17 -11
  104. data/lib/simplecov/exit_codes/exit_code_handling.rb +21 -8
  105. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +7 -10
  106. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  107. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  108. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +7 -12
  109. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +11 -10
  110. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +7 -9
  111. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +16 -17
  112. data/lib/simplecov/exit_codes.rb +16 -10
  113. data/lib/simplecov/exit_handling.rb +99 -62
  114. data/lib/simplecov/file_list.rb +20 -51
  115. data/lib/simplecov/filter.rb +43 -66
  116. data/lib/simplecov/formatter/base.rb +37 -55
  117. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  118. data/lib/simplecov/formatter/coverage_json_writer.rb +29 -31
  119. data/lib/simplecov/formatter/html_formatter/public/index.html +15 -9
  120. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +74 -17
  121. data/lib/simplecov/formatter/html_formatter.rb +24 -41
  122. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +50 -16
  123. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  124. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +43 -29
  125. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +14 -10
  126. data/lib/simplecov/formatter/json_formatter.rb +7 -13
  127. data/lib/simplecov/formatter/multi_formatter.rb +5 -11
  128. data/lib/simplecov/formatter/simple_formatter.rb +2 -8
  129. data/lib/simplecov/formatter.rb +14 -10
  130. data/lib/simplecov/group_names.rb +13 -12
  131. data/lib/simplecov/history.rb +115 -0
  132. data/lib/simplecov/last_run.rb +6 -9
  133. data/lib/simplecov/lines_classifier.rb +31 -16
  134. data/lib/simplecov/load_global_config.rb +7 -7
  135. data/lib/simplecov/parallel_adapters/base.rb +13 -41
  136. data/lib/simplecov/parallel_adapters/generic.rb +7 -19
  137. data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
  138. data/lib/simplecov/parallel_adapters.rb +24 -55
  139. data/lib/simplecov/parallel_coordination.rb +39 -49
  140. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  141. data/lib/simplecov/parallel_result_merger.rb +67 -126
  142. data/lib/simplecov/process.rb +14 -32
  143. data/lib/simplecov/production/error.rb +11 -0
  144. data/lib/simplecov/production/file_sink.rb +130 -0
  145. data/lib/simplecov/production.rb +249 -0
  146. data/lib/simplecov/profiles/rails.rb +4 -15
  147. data/lib/simplecov/profiles/root_filter.rb +0 -4
  148. data/lib/simplecov/profiles/strict.rb +11 -21
  149. data/lib/simplecov/profiles.rb +11 -33
  150. data/lib/simplecov/report_deferral.rb +11 -15
  151. data/lib/simplecov/report_stamp.rb +6 -9
  152. data/lib/simplecov/result/filter_config.rb +19 -0
  153. data/lib/simplecov/result/missing_source_files_reporter.rb +8 -9
  154. data/lib/simplecov/result/serialization.rb +43 -0
  155. data/lib/simplecov/result/source_file_builder.rb +7 -12
  156. data/lib/simplecov/result.rb +74 -117
  157. data/lib/simplecov/result_adapter.rb +56 -68
  158. data/lib/simplecov/result_merger/contexts.rb +22 -0
  159. data/lib/simplecov/result_merger/legacy_format_adapter.rb +4 -8
  160. data/lib/simplecov/result_merger/resultset_file.rb +20 -27
  161. data/lib/simplecov/result_merger/resultset_run_identity.rb +23 -25
  162. data/lib/simplecov/result_merger/resultset_store.rb +23 -14
  163. data/lib/simplecov/result_merger/unloaded_files.rb +24 -32
  164. data/lib/simplecov/result_merger.rb +59 -58
  165. data/lib/simplecov/result_processing.rb +92 -122
  166. data/lib/simplecov/run_identity.rb +16 -18
  167. data/lib/simplecov/simulate_coverage.rb +34 -50
  168. data/lib/simplecov/source_file/branch.rb +8 -33
  169. data/lib/simplecov/source_file/branch_builder.rb +28 -47
  170. data/lib/simplecov/source_file/builder_context.rb +13 -13
  171. data/lib/simplecov/source_file/line.rb +13 -33
  172. data/lib/simplecov/source_file/line_builder.rb +9 -15
  173. data/lib/simplecov/source_file/method.rb +3 -7
  174. data/lib/simplecov/source_file/method_builder.rb +6 -15
  175. data/lib/simplecov/source_file/ruby_data_parser.rb +59 -66
  176. data/lib/simplecov/source_file/skip_chunks.rb +29 -29
  177. data/lib/simplecov/source_file/source_loader.rb +45 -35
  178. data/lib/simplecov/source_file/statistics.rb +20 -9
  179. data/lib/simplecov/source_file.rb +21 -51
  180. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +55 -139
  181. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
  182. data/lib/simplecov/static_coverage_extractor/method_collector.rb +9 -26
  183. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +15 -24
  184. data/lib/simplecov/static_coverage_extractor/value_position.rb +25 -38
  185. data/lib/simplecov/static_coverage_extractor/visitor.rb +46 -65
  186. data/lib/simplecov/static_coverage_extractor.rb +63 -75
  187. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  188. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  189. data/lib/simplecov/test_tracker/delta.rb +69 -0
  190. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  191. data/lib/simplecov/test_tracker.rb +162 -0
  192. data/lib/simplecov/unloaded_file_injector.rb +18 -31
  193. data/lib/simplecov/useless_results_remover.rb +7 -6
  194. data/lib/simplecov/version.rb +1 -1
  195. data/lib/simplecov/view_coverage/template_compiler.rb +92 -0
  196. data/lib/simplecov/view_coverage.rb +55 -0
  197. data/lib/simplecov-html.rb +1 -0
  198. data/lib/simplecov.rb +57 -113
  199. data/lib/simplecov_json_formatter.rb +1 -0
  200. data/man/simplecov.1 +354 -0
  201. data/sig/simplecov.rbs +528 -595
  202. metadata +81 -6
@@ -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,147 @@
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/runner"
9
+ require_relative "affected/selection"
10
+
11
+ module SimpleCov
12
+ module CLI
13
+ # `simplecov affected`: select the test files whose recorded tests touch the
14
+ # code changed since a git base ref, from a coverage.json written under
15
+ # `track_tests`. The change is everything between the merge base of `--base`
16
+ # and the working tree, plus untracked files.
17
+ #
18
+ # The set intersection is the easy half; the hard half is knowing when to
19
+ # distrust the map, because a test map is stale the moment something changes
20
+ # that no test mentions by name. Any changed file outside the tracked set
21
+ # fails open to the full suite, out loud: each trigger is named on stderr and
22
+ # stdout stays empty, which a bare runner reads as "run everything". Test
23
+ # files always select themselves, recorded or not.
24
+ module Affected
25
+ extend CommandHelpers
26
+
27
+ extend self
28
+
29
+ def run(args, stdout:, stderr:)
30
+ opts = parse(args)
31
+ issue = precheck(opts)
32
+ return error(stderr, issue) if issue
33
+
34
+ document = CoverageFile.load_document(opts.fetch(:input), command: "affected", stderr: stderr)
35
+ return 1 unless document
36
+
37
+ contexts = recorded_contexts(document, opts, stderr)
38
+ return 1 unless contexts
39
+
40
+ diffed = ChangedFiles.call(opts[:base] ||= Git.default_base, stderr)
41
+ return 1 unless diffed
42
+
43
+ respond(diffed, document, contexts, opts, stdout: stdout, stderr: stderr)
44
+ end
45
+
46
+ def parse(args)
47
+ head, runner = split_runner(args)
48
+ opts, rest = parse_common(head, base: nil, run: runner) do |parser, into|
49
+ parser.on("--base REF") { |v| into[:base] = v }
50
+ end
51
+ opts[:rest] = rest
52
+ opts
53
+ end
54
+
55
+ # Everything after `--run` is the runner command, verbatim, so the command's
56
+ # own flags never collide with ours.
57
+ def split_runner(args)
58
+ index = args.index("--run")
59
+ return [args, nil] unless index
60
+
61
+ [args.take(index), args.drop(index + 1)]
62
+ end
63
+
64
+ # A stray positional looks exactly like a forgotten `--base` ref, and
65
+ # silently dropping it would select tests for the wrong change.
66
+ def precheck(opts)
67
+ stray = opts.fetch(:rest).first
68
+ return "unexpected argument #{stray.inspect} (did you mean `--base #{stray}`?)" if stray
69
+ return "missing command after --run" if opts.fetch(:run) && opts.fetch(:run).empty?
70
+
71
+ "--run and --json cannot be combined" if opts.fetch(:run) && opts.fetch(:json)
72
+ end
73
+
74
+ def respond(diffed, document, contexts, opts, stdout:, stderr:)
75
+ opts[:root] = diffed.fetch(:root)
76
+ return no_changes(opts, stdout, stderr) if diffed.fetch(:changed).empty?
77
+
78
+ selection = Selection.build(diffed.fetch(:changed), document, contexts, opts, stderr, root: diffed.fetch(:root))
79
+ return 1 unless selection
80
+
81
+ deliver(selection, opts, stdout, stderr)
82
+ end
83
+
84
+ def no_changes(opts, stdout, stderr)
85
+ stderr.puts("simplecov affected: no changes against #{opts.fetch(:base)}")
86
+ empty = {tests: [], triggers: []} #: Hash[Symbol, Array[String]]
87
+ emit_json(empty, false, stdout) if opts.fetch(:json)
88
+ 0
89
+ end
90
+
91
+ def deliver(selection, opts, stdout, stderr)
92
+ full = !selection.fetch(:triggers).empty?
93
+ selection.fetch(:triggers).each { |trigger| stderr.puts("simplecov affected: #{trigger}") }
94
+ stderr.puts("simplecov affected: falling back to the full suite") if full
95
+ return emit_json(selection, full, stdout) if opts.fetch(:json)
96
+ return execute(selection, full, opts, stderr) if opts.fetch(:run)
97
+
98
+ emit_text(selection, full, stdout, stderr)
99
+ end
100
+
101
+ def emit_json(selection, full, stdout)
102
+ none = [] #: Array[String]
103
+ stdout.puts(JSON.pretty_generate("full_suite" => full, "triggers" => selection.fetch(:triggers),
104
+ "tests" => full ? none : selection.fetch(:tests)))
105
+ 0
106
+ end
107
+
108
+ # Stdout carries the selected files and nothing else, so the list can feed a
109
+ # runner directly, and a full-suite fallback prints nothing, which
110
+ # `rspec $(simplecov affected)` reads as "run everything".
111
+ def emit_text(selection, full, stdout, stderr)
112
+ return 0 if full
113
+
114
+ tests = selection.fetch(:tests)
115
+ if tests.empty?
116
+ note_untouched(stderr)
117
+ else
118
+ tests.each { |file| stdout.puts(file) }
119
+ 0
120
+ end
121
+ end
122
+
123
+ def note_untouched(stderr)
124
+ stderr.puts("simplecov affected: no recorded test touches the changed code")
125
+ 0
126
+ end
127
+
128
+ def execute(selection, full, opts, stderr)
129
+ return run_command(opts.fetch(:run), opts.fetch(:root), stderr) if full
130
+ return note_untouched(stderr) if selection.fetch(:tests).empty?
131
+
132
+ count = selection.fetch(:tests).size
133
+ stderr.puts("simplecov affected: running #{count} test file#{"s" unless count.eql?(1)}")
134
+ run_command(opts.fetch(:run) + selection.fetch(:tests), opts.fetch(:root), stderr)
135
+ end
136
+
137
+ # The command is named explicitly in the failure because not every
138
+ # engine's exception message carries it.
139
+ def run_command(command, root, stderr)
140
+ RUNNER.call(command, root)
141
+ rescue SystemCallError => e
142
+ stderr.puts("simplecov affected: cannot run #{command.first.inspect} (#{e})")
143
+ 127
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module SimpleCov
7
+ module CLI
8
+ # The `--annotate KIND` output shared by `uncovered` and `patch`: each command
9
+ # reduces its answer to diagnostics (a path, a contiguous missed line range,
10
+ # and the criterion that missed), and one emitter per CI host renders them in
11
+ # that host's native inline-annotation channel, so a gap surfaces on the diff
12
+ # with no upload step and no extra gem.
13
+ module Annotations
14
+ KINDS = %w[github gitlab rdjson azure teamcity buildkite].freeze
15
+
16
+ MESSAGES = {
17
+ line: "Not covered by tests",
18
+ branch: "Branch not covered by tests",
19
+ method: "Method not covered by tests"
20
+ }.freeze
21
+
22
+ DESCRIPTIONS = {
23
+ line: "Lines the tests never executed",
24
+ branch: "Branches the tests never took",
25
+ method: "Methods the tests never called"
26
+ }.freeze
27
+
28
+ SOURCE = {"name" => "simplecov", "url" => "https://github.com/simplecov-ruby/simplecov"}.freeze
29
+
30
+ # TeamCity's service message grammar and Azure's logging command grammar
31
+ # each reserve a few characters in attribute values.
32
+ TEAMCITY_ESCAPES = {"|" => "||", "'" => "|'", "[" => "|[", "]" => "|]", "\n" => "|n", "\r" => "|r"}.freeze
33
+ AZURE_ESCAPES = {"%" => "%AZP25", ";" => "%3B", "]" => "%5D", "\n" => "%0A", "\r" => "%0D"}.freeze
34
+
35
+ extend self
36
+
37
+ def issue(opts)
38
+ kind = opts.fetch(:annotate)
39
+ return nil unless kind
40
+ return "unknown --annotate #{kind.inspect} (expected #{expected_kinds})" unless KINDS.include?(kind)
41
+
42
+ "cannot combine --annotate with --json" if opts.fetch(:json)
43
+ end
44
+
45
+ def expected_kinds
46
+ *rest, last = KINDS
47
+ "#{rest.join(", ")}, or #{last}"
48
+ end
49
+
50
+ def diagnostics(path, missed, criterion)
51
+ missed.slice_when { |previous, current| current > previous + 1 }.map do |run|
52
+ {path: path, first: run.first, last: run.last, criterion: criterion, message: MESSAGES.fetch(criterion)}
53
+ end
54
+ end
55
+
56
+ def emit(stdout, kind, diagnostics)
57
+ case kind
58
+ when "github" then github(stdout, diagnostics)
59
+ when "gitlab" then stdout.puts(JSON.pretty_generate(gitlab(diagnostics)))
60
+ when "rdjson" then stdout.puts(JSON.pretty_generate(rdjson(diagnostics)))
61
+ when "azure" then azure(stdout, diagnostics)
62
+ when "teamcity" then teamcity(stdout, diagnostics)
63
+ else buildkite(stdout, diagnostics)
64
+ end
65
+ end
66
+
67
+ def github(stdout, diagnostics)
68
+ diagnostics.each do |diagnostic|
69
+ stdout.puts("::warning file=#{diagnostic.fetch(:path)},line=#{diagnostic.fetch(:first)}," \
70
+ "endLine=#{diagnostic.fetch(:last)}::#{diagnostic.fetch(:message)}")
71
+ end
72
+ end
73
+
74
+ # GitLab's Code Quality report, a subset of the Code Climate spec, read from
75
+ # a `codequality` artifact for merge request annotations.
76
+ def gitlab(diagnostics)
77
+ diagnostics.map do |diagnostic|
78
+ {
79
+ "description" => diagnostic.fetch(:message),
80
+ "check_name" => "coverage",
81
+ "fingerprint" => fingerprint(diagnostic),
82
+ "severity" => "minor",
83
+ "location" => {
84
+ "path" => diagnostic.fetch(:path),
85
+ "lines" => {"begin" => diagnostic.fetch(:first), "end" => diagnostic.fetch(:last)}
86
+ }
87
+ }
88
+ end
89
+ end
90
+
91
+ def fingerprint(diagnostic)
92
+ Digest::SHA256.hexdigest("#{diagnostic.fetch(:path)}:#{diagnostic.fetch(:first)}-#{diagnostic.fetch(:last)}:" \
93
+ "#{diagnostic.fetch(:criterion)}")
94
+ end
95
+
96
+ # reviewdog's Diagnostic Format, which reviewdog posts to whichever host it
97
+ # runs under.
98
+ def rdjson(diagnostics)
99
+ {"source" => SOURCE, "severity" => "WARNING", "diagnostics" => diagnostics.map { |d| rdjson_diagnostic(d) }}
100
+ end
101
+
102
+ def rdjson_diagnostic(diagnostic)
103
+ {
104
+ "message" => diagnostic.fetch(:message),
105
+ "location" => {
106
+ "path" => diagnostic.fetch(:path),
107
+ "range" => {"start" => {"line" => diagnostic.fetch(:first)}, "end" => {"line" => diagnostic.fetch(:last)}}
108
+ },
109
+ "severity" => "WARNING",
110
+ "code" => {"value" => diagnostic.fetch(:criterion).to_s}
111
+ }
112
+ end
113
+
114
+ # Azure Pipelines logging commands carry one line number, so a range is
115
+ # spelled out in the message.
116
+ def azure(stdout, diagnostics)
117
+ diagnostics.each do |diagnostic|
118
+ stdout.puts("##vso[task.logissue type=warning;sourcepath=#{escape(diagnostic.fetch(:path), AZURE_ESCAPES)};" \
119
+ "linenumber=#{diagnostic.fetch(:first)}]#{described(diagnostic)}")
120
+ end
121
+ end
122
+
123
+ # TeamCity's code inspection service messages: each criterion is one
124
+ # inspection type, declared once before its first inspection.
125
+ def teamcity(stdout, diagnostics)
126
+ diagnostics.group_by { |diagnostic| diagnostic.fetch(:criterion) }.each do |criterion, group|
127
+ id = "simplecov.#{criterion}"
128
+ stdout.puts(service_message("inspectionType", id: id, name: MESSAGES.fetch(criterion),
129
+ description: DESCRIPTIONS.fetch(criterion), category: "Code coverage"))
130
+ group.each do |diagnostic|
131
+ stdout.puts(service_message("inspection", typeId: id, message: described(diagnostic),
132
+ file: diagnostic.fetch(:path), line: diagnostic.fetch(:first), SEVERITY: "WARNING"))
133
+ end
134
+ end
135
+ end
136
+
137
+ def service_message(name, attributes)
138
+ body = attributes.map { |key, value| "#{key}='#{escape(value.to_s, TEAMCITY_ESCAPES)}'" }.join(" ")
139
+ "##teamcity[#{name} #{body}]"
140
+ end
141
+
142
+ # Buildkite has no per-line channel; its annotations are Markdown, so this
143
+ # is the body for `buildkite-agent annotate`.
144
+ def buildkite(stdout, diagnostics)
145
+ sections = diagnostics.group_by { |diagnostic| diagnostic.fetch(:message) }.map do |message, group|
146
+ (["#### #{message}"] + group.map { |diagnostic| "- `#{diagnostic.fetch(:path)}:#{range(diagnostic)}`" }).join("\n")
147
+ end
148
+ stdout.puts(sections.join("\n\n")) unless sections.empty?
149
+ end
150
+
151
+ def described(diagnostic)
152
+ return diagnostic.fetch(:message) if diagnostic.fetch(:first).equal?(diagnostic.fetch(:last))
153
+
154
+ "#{diagnostic.fetch(:message)} (lines #{range(diagnostic)})"
155
+ end
156
+
157
+ def range(diagnostic)
158
+ [diagnostic.fetch(:first), diagnostic.fetch(:last)].uniq.join("-")
159
+ end
160
+
161
+ def escape(text, escapes)
162
+ text.gsub(Regexp.union(escapes.keys), escapes)
163
+ end
164
+ end
165
+ end
166
+ 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,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
3
4
  require "optparse"
4
5
  require_relative "command_helpers"
6
+ require_relative "real_path"
5
7
 
6
8
  module SimpleCov
7
9
  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.
10
+ # `simplecov clean [--dry-run]`: remove the coverage report directory.
11
+ # `--dry-run` prints what would be deleted without touching disk.
12
12
  module Clean
13
13
  extend CommandHelpers
14
14
 
15
- module_function
15
+ extend self
16
16
 
17
17
  def run(args, stdout:, stderr:, **)
18
18
  opts = parse(args)
19
- dir = SimpleCov::CLI.coverage_dir
19
+ dir = CLI.coverage_dir
20
20
  return error(stderr, "refusing to remove unsafe coverage directory #{dir.inspect}") unless safe_to_remove?(dir)
21
21
  return announce(stdout, opts, "#{dir} doesn't exist; nothing to do") || 0 unless File.directory?(dir)
22
22
 
@@ -25,60 +25,60 @@ module SimpleCov
25
25
  end
26
26
 
27
27
  def sweep(dir, opts, stdout)
28
- if opts[:dry_run]
28
+ if opts.fetch(:dry_run)
29
29
  announce(stdout, opts, "would remove #{dir} (#{entry_count(dir)} entries)")
30
30
  else
31
- require "fileutils"
32
31
  FileUtils.rm_rf(dir)
33
32
  announce(stdout, opts, "removed #{dir}")
34
33
  end
35
34
  end
36
35
 
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
36
+ # FNM_DOTMATCH so the dotfiles rm_rf will actually delete count too. The
37
+ # glob still yields the directory's own "." entry, which rm_rf does not
40
38
  # remove separately.
41
39
  def entry_count(dir)
42
40
  Dir.glob("#{dir}/**/*", File::FNM_DOTMATCH).count { |path| !path.end_with?("/.") }
43
41
  end
44
42
 
45
43
  def announce(stdout, opts, message)
46
- stdout.puts("simplecov #{command_name}: #{message}") unless opts[:quiet]
44
+ stdout.puts("simplecov #{command_name}: #{message}") unless opts.fetch(:quiet)
47
45
  end
48
46
 
47
+ # A filesystem root is its own dirname, on every platform and every Windows
48
+ # drive. The descendant check only protects roots that happen to contain the
49
+ # cwd, which misses other drives, so roots are refused outright.
49
50
  def safe_to_remove?(dir)
50
51
  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
52
+ return false if File.dirname(target).eql?(target)
56
53
 
57
- protected_paths.none? { |path| path == target || descendant_of?(path, target) }
54
+ protected_paths.none? { |path| path.eql?(target) || descendant_of?(path, target) }
58
55
  end
59
56
 
57
+ # The working directory, which a coverage directory must not be or contain.
58
+ # The `.simplecov` project root needs no entry of its own: `Dotfile.find`
59
+ # walks up from the working directory, so any target containing that root
60
+ # contains the working directory too.
60
61
  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
62
+ [canonical_path(Dir.pwd)]
63
63
  end
64
64
 
65
- # Canonical paths only carry a trailing separator on a filesystem
66
- # root, and roots are refused before this check runs.
65
+ # Canonical paths only carry a trailing separator on a filesystem root, and
66
+ # roots are refused before this check runs.
67
67
  def descendant_of?(path, possible_ancestor)
68
68
  path.start_with?("#{possible_ancestor}#{File::SEPARATOR}")
69
69
  end
70
70
 
71
71
  def canonical_path(path)
72
- File.realpath(path)
72
+ REAL_PATHS.realpath(path)
73
73
  rescue SystemCallError
74
74
  File.expand_path(path)
75
75
  end
76
76
 
77
77
  def parse(args)
78
78
  opts = {dry_run: false, quiet: false}
79
- OptionParser.new do |o|
79
+ build_parser do |o|
80
80
  o.on("--dry-run") { opts[:dry_run] = true }
81
- o.on("-q", "--quiet") { opts[:quiet] = true }
81
+ quiet_option(o, opts)
82
82
  end.parse(args)
83
83
  opts
84
84
  end