simplecov 1.1.1 → 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.
Files changed (200) 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/annotations.rb +166 -0
  13. data/lib/simplecov/cli/badge/svg.rb +70 -0
  14. data/lib/simplecov/cli/badge.rb +62 -0
  15. data/lib/simplecov/cli/clean.rb +23 -24
  16. data/lib/simplecov/cli/command_helpers.rb +55 -19
  17. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  18. data/lib/simplecov/cli/completions.rb +83 -0
  19. data/lib/simplecov/cli/coverage.rb +25 -28
  20. data/lib/simplecov/cli/coverage_file.rb +58 -14
  21. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  22. data/lib/simplecov/cli/dead_code.rb +139 -0
  23. data/lib/simplecov/cli/diff/output.rb +45 -0
  24. data/lib/simplecov/cli/diff.rb +35 -67
  25. data/lib/simplecov/cli/dotfile.rb +83 -38
  26. data/lib/simplecov/cli/git.rb +41 -0
  27. data/lib/simplecov/cli/history/output.rb +135 -0
  28. data/lib/simplecov/cli/history.rb +67 -0
  29. data/lib/simplecov/cli/merge.rb +34 -39
  30. data/lib/simplecov/cli/open.rb +12 -15
  31. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  32. data/lib/simplecov/cli/patch/output.rb +151 -0
  33. data/lib/simplecov/cli/patch.rb +175 -0
  34. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  35. data/lib/simplecov/cli/ratchet.rb +83 -0
  36. data/lib/simplecov/cli/report.rb +18 -24
  37. data/lib/simplecov/cli/run.rb +8 -9
  38. data/lib/simplecov/cli/serve/report_preparer.rb +7 -6
  39. data/lib/simplecov/cli/serve/static_file_handler.rb +62 -56
  40. data/lib/simplecov/cli/serve.rb +27 -24
  41. data/lib/simplecov/cli/show/annotator.rb +87 -0
  42. data/lib/simplecov/cli/show/sweep.rb +36 -0
  43. data/lib/simplecov/cli/show.rb +133 -0
  44. data/lib/simplecov/cli/status/facts.rb +87 -0
  45. data/lib/simplecov/cli/status.rb +93 -0
  46. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  47. data/lib/simplecov/cli/tests.rb +148 -0
  48. data/lib/simplecov/cli/uncovered/misses.rb +33 -0
  49. data/lib/simplecov/cli/uncovered.rb +58 -34
  50. data/lib/simplecov/cli/usage.rb +153 -0
  51. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  52. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  53. data/lib/simplecov/cli/watch/poller.rb +45 -0
  54. data/lib/simplecov/cli/watch/session.rb +134 -0
  55. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  56. data/lib/simplecov/cli/watch.rb +85 -0
  57. data/lib/simplecov/cli.rb +43 -92
  58. data/lib/simplecov/color.rb +19 -37
  59. data/lib/simplecov/combine/branches_combiner.rb +7 -31
  60. data/lib/simplecov/combine/coverage_accumulator.rb +49 -142
  61. data/lib/simplecov/combine/identity_interner.rb +10 -13
  62. data/lib/simplecov/combine/interned_counts.rb +7 -9
  63. data/lib/simplecov/combine/lines_combiner.rb +20 -44
  64. data/lib/simplecov/combine/methods_combiner.rb +9 -35
  65. data/lib/simplecov/combine/results_combiner.rb +4 -21
  66. data/lib/simplecov/combine.rb +0 -6
  67. data/lib/simplecov/command_guesser.rb +20 -34
  68. data/lib/simplecov/configuration/baseline.rb +31 -0
  69. data/lib/simplecov/configuration/coverage.rb +91 -79
  70. data/lib/simplecov/configuration/coverage_criteria.rb +43 -54
  71. data/lib/simplecov/configuration/deprecations.rb +21 -0
  72. data/lib/simplecov/configuration/eval_coverage.rb +7 -11
  73. data/lib/simplecov/configuration/filters.rb +40 -73
  74. data/lib/simplecov/configuration/formatting.rb +74 -83
  75. data/lib/simplecov/configuration/groups.rb +19 -13
  76. data/lib/simplecov/configuration/history.rb +44 -0
  77. data/lib/simplecov/configuration/ignored_entries.rb +31 -22
  78. data/lib/simplecov/configuration/merging.rb +94 -62
  79. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  80. data/lib/simplecov/configuration/production.rb +23 -0
  81. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  82. data/lib/simplecov/configuration/thresholds.rb +33 -62
  83. data/lib/simplecov/configuration/view_coverage.rb +37 -0
  84. data/lib/simplecov/configuration.rb +70 -76
  85. data/lib/simplecov/configuration_error.rb +5 -0
  86. data/lib/simplecov/context_map/union.rb +81 -0
  87. data/lib/simplecov/context_map.rb +187 -0
  88. data/lib/simplecov/coverage_json.rb +2 -5
  89. data/lib/simplecov/coverage_statistics.rb +14 -24
  90. data/lib/simplecov/coverage_violations.rb +160 -65
  91. data/lib/simplecov/current_run.rb +61 -0
  92. data/lib/simplecov/defaults.rb +4 -23
  93. data/lib/simplecov/deprecation.rb +45 -27
  94. data/lib/simplecov/directive/erb.rb +44 -0
  95. data/lib/simplecov/directive/haml.rb +33 -0
  96. data/lib/simplecov/directive/indented_template.rb +37 -0
  97. data/lib/simplecov/directive/slim.rb +35 -0
  98. data/lib/simplecov/directive/template.rb +29 -0
  99. data/lib/simplecov/directive.rb +47 -62
  100. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  101. data/lib/simplecov/exit_codes/check.rb +17 -11
  102. data/lib/simplecov/exit_codes/exit_code_handling.rb +21 -8
  103. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +7 -10
  104. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  105. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  106. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +7 -12
  107. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +11 -10
  108. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +7 -9
  109. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +16 -17
  110. data/lib/simplecov/exit_codes.rb +16 -10
  111. data/lib/simplecov/exit_handling.rb +94 -59
  112. data/lib/simplecov/file_list.rb +20 -51
  113. data/lib/simplecov/filter.rb +43 -66
  114. data/lib/simplecov/formatter/base.rb +37 -55
  115. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  116. data/lib/simplecov/formatter/coverage_json_writer.rb +29 -31
  117. data/lib/simplecov/formatter/html_formatter/public/index.html +15 -9
  118. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +74 -17
  119. data/lib/simplecov/formatter/html_formatter.rb +24 -41
  120. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +50 -16
  121. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  122. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +43 -29
  123. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +14 -10
  124. data/lib/simplecov/formatter/json_formatter.rb +7 -13
  125. data/lib/simplecov/formatter/multi_formatter.rb +5 -11
  126. data/lib/simplecov/formatter/simple_formatter.rb +2 -8
  127. data/lib/simplecov/formatter.rb +14 -10
  128. data/lib/simplecov/group_names.rb +13 -12
  129. data/lib/simplecov/history.rb +115 -0
  130. data/lib/simplecov/last_run.rb +6 -9
  131. data/lib/simplecov/lines_classifier.rb +31 -16
  132. data/lib/simplecov/load_global_config.rb +7 -7
  133. data/lib/simplecov/parallel_adapters/base.rb +13 -41
  134. data/lib/simplecov/parallel_adapters/generic.rb +7 -19
  135. data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
  136. data/lib/simplecov/parallel_adapters.rb +24 -55
  137. data/lib/simplecov/parallel_coordination.rb +39 -49
  138. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  139. data/lib/simplecov/parallel_result_merger.rb +67 -126
  140. data/lib/simplecov/process.rb +14 -32
  141. data/lib/simplecov/production/error.rb +11 -0
  142. data/lib/simplecov/production/file_sink.rb +130 -0
  143. data/lib/simplecov/production.rb +249 -0
  144. data/lib/simplecov/profiles/rails.rb +4 -15
  145. data/lib/simplecov/profiles/root_filter.rb +0 -4
  146. data/lib/simplecov/profiles/strict.rb +11 -21
  147. data/lib/simplecov/profiles.rb +11 -33
  148. data/lib/simplecov/report_deferral.rb +11 -15
  149. data/lib/simplecov/report_stamp.rb +6 -9
  150. data/lib/simplecov/result/filter_config.rb +19 -0
  151. data/lib/simplecov/result/missing_source_files_reporter.rb +8 -9
  152. data/lib/simplecov/result/serialization.rb +43 -0
  153. data/lib/simplecov/result/source_file_builder.rb +7 -12
  154. data/lib/simplecov/result.rb +74 -117
  155. data/lib/simplecov/result_adapter.rb +56 -68
  156. data/lib/simplecov/result_merger/contexts.rb +22 -0
  157. data/lib/simplecov/result_merger/legacy_format_adapter.rb +4 -8
  158. data/lib/simplecov/result_merger/resultset_file.rb +20 -27
  159. data/lib/simplecov/result_merger/resultset_run_identity.rb +23 -25
  160. data/lib/simplecov/result_merger/resultset_store.rb +23 -14
  161. data/lib/simplecov/result_merger/unloaded_files.rb +24 -32
  162. data/lib/simplecov/result_merger.rb +59 -58
  163. data/lib/simplecov/result_processing.rb +92 -122
  164. data/lib/simplecov/run_identity.rb +16 -18
  165. data/lib/simplecov/simulate_coverage.rb +34 -50
  166. data/lib/simplecov/source_file/branch.rb +8 -33
  167. data/lib/simplecov/source_file/branch_builder.rb +28 -47
  168. data/lib/simplecov/source_file/builder_context.rb +13 -13
  169. data/lib/simplecov/source_file/line.rb +13 -33
  170. data/lib/simplecov/source_file/line_builder.rb +9 -15
  171. data/lib/simplecov/source_file/method.rb +3 -7
  172. data/lib/simplecov/source_file/method_builder.rb +6 -15
  173. data/lib/simplecov/source_file/ruby_data_parser.rb +59 -66
  174. data/lib/simplecov/source_file/skip_chunks.rb +29 -29
  175. data/lib/simplecov/source_file/source_loader.rb +45 -35
  176. data/lib/simplecov/source_file/statistics.rb +20 -9
  177. data/lib/simplecov/source_file.rb +21 -51
  178. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +55 -139
  179. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
  180. data/lib/simplecov/static_coverage_extractor/method_collector.rb +9 -26
  181. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +15 -24
  182. data/lib/simplecov/static_coverage_extractor/value_position.rb +25 -38
  183. data/lib/simplecov/static_coverage_extractor/visitor.rb +45 -65
  184. data/lib/simplecov/static_coverage_extractor.rb +43 -75
  185. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  186. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  187. data/lib/simplecov/test_tracker/delta.rb +69 -0
  188. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  189. data/lib/simplecov/test_tracker.rb +162 -0
  190. data/lib/simplecov/unloaded_file_injector.rb +18 -31
  191. data/lib/simplecov/useless_results_remover.rb +7 -6
  192. data/lib/simplecov/version.rb +1 -1
  193. data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
  194. data/lib/simplecov/view_coverage.rb +55 -0
  195. data/lib/simplecov-html.rb +1 -0
  196. data/lib/simplecov.rb +57 -113
  197. data/lib/simplecov_json_formatter.rb +1 -0
  198. data/man/simplecov.1 +354 -0
  199. data/sig/simplecov.rbs +528 -595
  200. metadata +79 -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,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,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,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