simplecov 1.0.3 → 1.1.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -1439
  3. data/lib/simplecov/atomic_file.rb +70 -0
  4. data/lib/simplecov/cli/clean.rb +43 -3
  5. data/lib/simplecov/cli/command_helpers.rb +55 -0
  6. data/lib/simplecov/cli/coverage.rb +21 -28
  7. data/lib/simplecov/cli/coverage_file.rb +65 -0
  8. data/lib/simplecov/cli/diff.rb +43 -48
  9. data/lib/simplecov/cli/dotfile.rb +12 -6
  10. data/lib/simplecov/cli/merge.rb +12 -10
  11. data/lib/simplecov/cli/open.rb +3 -5
  12. data/lib/simplecov/cli/report.rb +31 -23
  13. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  14. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  15. data/lib/simplecov/cli/serve.rb +30 -94
  16. data/lib/simplecov/cli/uncovered.rb +20 -25
  17. data/lib/simplecov/cli.rb +15 -2
  18. data/lib/simplecov/combine/branches_combiner.rb +43 -19
  19. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  20. data/lib/simplecov/combine/identity_interner.rb +30 -0
  21. data/lib/simplecov/combine/interned_counts.rb +30 -0
  22. data/lib/simplecov/combine/lines_combiner.rb +48 -20
  23. data/lib/simplecov/combine/methods_combiner.rb +36 -12
  24. data/lib/simplecov/combine/results_combiner.rb +12 -37
  25. data/lib/simplecov/combine.rb +5 -23
  26. data/lib/simplecov/command_guesser.rb +66 -9
  27. data/lib/simplecov/configuration/coverage.rb +12 -15
  28. data/lib/simplecov/configuration/coverage_criteria.rb +34 -38
  29. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  30. data/lib/simplecov/configuration/filters.rb +11 -44
  31. data/lib/simplecov/configuration/formatting.rb +6 -3
  32. data/lib/simplecov/configuration/groups.rb +42 -0
  33. data/lib/simplecov/configuration/merging.rb +8 -7
  34. data/lib/simplecov/configuration/thresholds.rb +14 -13
  35. data/lib/simplecov/configuration.rb +13 -43
  36. data/lib/simplecov/coverage_json.rb +24 -0
  37. data/lib/simplecov/coverage_statistics.rb +1 -1
  38. data/lib/simplecov/coverage_violations.rb +15 -5
  39. data/lib/simplecov/defaults.rb +7 -3
  40. data/lib/simplecov/directive.rb +1 -1
  41. data/lib/simplecov/exit_codes/check.rb +33 -0
  42. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +11 -20
  43. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +3 -16
  44. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +14 -23
  45. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +14 -25
  46. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +4 -17
  47. data/lib/simplecov/exit_codes.rb +1 -0
  48. data/lib/simplecov/exit_handling.rb +11 -41
  49. data/lib/simplecov/file_list.rb +5 -10
  50. data/lib/simplecov/filter.rb +43 -9
  51. data/lib/simplecov/formatter/base.rb +11 -0
  52. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  53. data/lib/simplecov/formatter/html_formatter/public/index.html +35 -5
  54. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  55. data/lib/simplecov/formatter/html_formatter.rb +67 -47
  56. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +54 -56
  57. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +78 -88
  58. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +70 -76
  59. data/lib/simplecov/formatter/json_formatter.rb +5 -48
  60. data/lib/simplecov/formatter/multi_formatter.rb +1 -1
  61. data/lib/simplecov/formatter/simple_formatter.rb +8 -5
  62. data/lib/simplecov/formatter.rb +5 -0
  63. data/lib/simplecov/group_names.rb +32 -0
  64. data/lib/simplecov/last_run.rb +16 -9
  65. data/lib/simplecov/lines_classifier.rb +29 -8
  66. data/lib/simplecov/load_global_config.rb +5 -2
  67. data/lib/simplecov/parallel_adapters/base.rb +17 -0
  68. data/lib/simplecov/parallel_adapters/generic.rb +2 -2
  69. data/lib/simplecov/parallel_adapters/parallel_tests.rb +2 -2
  70. data/lib/simplecov/parallel_coordination.rb +6 -1
  71. data/lib/simplecov/parallel_result_merger.rb +230 -0
  72. data/lib/simplecov/report_deferral.rb +49 -0
  73. data/lib/simplecov/report_stamp.rb +28 -0
  74. data/lib/simplecov/result.rb +40 -10
  75. data/lib/simplecov/result_merger/resultset_file.rb +43 -7
  76. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  77. data/lib/simplecov/result_merger/resultset_store.rb +15 -12
  78. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  79. data/lib/simplecov/result_merger.rb +69 -42
  80. data/lib/simplecov/result_processing.rb +82 -43
  81. data/lib/simplecov/run_identity.rb +77 -0
  82. data/lib/simplecov/simulate_coverage.rb +35 -11
  83. data/lib/simplecov/source_file/method.rb +7 -1
  84. data/lib/simplecov/source_file/ruby_data_parser.rb +6 -2
  85. data/lib/simplecov/source_file/skip_chunks.rb +7 -10
  86. data/lib/simplecov/source_file/source_loader.rb +23 -7
  87. data/lib/simplecov/source_file/statistics.rb +24 -16
  88. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +203 -13
  89. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +19 -30
  90. data/lib/simplecov/static_coverage_extractor/method_collector.rb +7 -0
  91. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  92. data/lib/simplecov/static_coverage_extractor/value_position.rb +6 -14
  93. data/lib/simplecov/static_coverage_extractor/visitor.rb +23 -36
  94. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  95. data/lib/simplecov/version.rb +1 -1
  96. data/lib/simplecov.rb +11 -5
  97. data/schemas/coverage-v1.0.schema.json +2 -2
  98. data/schemas/coverage.schema.json +2 -2
  99. data/sig/simplecov.rbs +200 -66
  100. metadata +28 -14
  101. data/doc/alternate-formatters.md +0 -66
  102. data/doc/commercial-services.md +0 -25
  103. data/doc/editor-integration.md +0 -18
  104. data/lib/simplecov/combine/files_combiner.rb +0 -70
  105. data/lib/simplecov/formatter/html_formatter/public/application.css +0 -1
  106. data/lib/simplecov/formatter/html_formatter/public/application.js +0 -18
  107. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  108. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  109. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
@@ -12,77 +12,75 @@ module SimpleCov
12
12
  CRITERION_KEYS = {line: :lines, branch: :branches, method: :methods}.freeze
13
13
  private_constant :CRITERION_KEYS
14
14
 
15
- def initialize(result)
16
- @result = result
17
- @errors = {}
18
- end
19
-
20
- def call
21
- format_minimum_overall
22
- format_minimum_by_file
23
- format_minimum_by_group
24
- format_maximum_overall
25
- format_maximum_drop
26
- @errors
27
- end
15
+ class << self
16
+ def call(result)
17
+ errors = {} #: Hash[Symbol, Hash[untyped, untyped]]
18
+ format_minimum_overall(result, errors)
19
+ format_minimum_by_file(result, errors)
20
+ format_minimum_by_group(result, errors)
21
+ format_maximum_overall(result, errors)
22
+ format_maximum_drop(result, errors)
23
+ errors
24
+ end
28
25
 
29
- private
26
+ private
30
27
 
31
- def format_minimum_overall
32
- SimpleCov::CoverageViolations.minimum_overall(@result, SimpleCov.minimum_coverage).each do |violation|
33
- bucket(:minimum_coverage)[key_for(violation)] = expected_actual(violation)
28
+ def format_minimum_overall(result, errors)
29
+ SimpleCov::CoverageViolations.minimum_overall(result, SimpleCov.minimum_coverage).each do |violation|
30
+ bucket(errors, :minimum_coverage)[key_for(violation)] = expected_actual(violation)
31
+ end
34
32
  end
35
- end
36
33
 
37
- def format_minimum_by_file
38
- violations = SimpleCov::CoverageViolations.minimum_by_file(
39
- @result, SimpleCov.minimum_coverage_by_file, SimpleCov.minimum_coverage_by_file_overrides
40
- )
41
- violations.each { |violation| record_by_file(violation) }
42
- end
34
+ def format_minimum_by_file(result, errors)
35
+ violations = SimpleCov::CoverageViolations.minimum_by_file(
36
+ result, SimpleCov.minimum_coverage_by_file, SimpleCov.minimum_coverage_by_file_overrides
37
+ )
38
+ violations.each { |violation| record_by_file(violation, errors) }
39
+ end
43
40
 
44
- def record_by_file(violation)
45
- by_file = bucket(:minimum_coverage_by_file)
46
- file_bucket = by_file[violation.fetch(:project_filename)] ||= {} #: Hash[untyped, untyped]
47
- file_bucket[key_for(violation)] = expected_actual(violation)
48
- end
41
+ def record_by_file(violation, errors)
42
+ by_file = bucket(errors, :minimum_coverage_by_file)
43
+ file_bucket = by_file[violation.fetch(:project_filename)] ||= {} #: Hash[untyped, untyped]
44
+ file_bucket[key_for(violation)] = expected_actual(violation)
45
+ end
49
46
 
50
- def format_minimum_by_group
51
- violations = SimpleCov::CoverageViolations.minimum_by_group(@result, SimpleCov.minimum_coverage_by_group)
52
- return if violations.empty?
47
+ def format_minimum_by_group(result, errors)
48
+ violations = SimpleCov::CoverageViolations.minimum_by_group(result, SimpleCov.minimum_coverage_by_group)
49
+ return if violations.empty?
53
50
 
54
- # `bucket` lazily creates the errors key, so only touch it when
55
- # there is a violation to record.
56
- by_group = bucket(:minimum_coverage_by_group)
57
- violations.each do |violation|
58
- group_bucket = by_group[violation.fetch(:group_name)] ||= {} #: Hash[untyped, untyped]
59
- group_bucket[key_for(violation)] = expected_actual(violation)
51
+ # `bucket` lazily creates the errors key, so only touch it when
52
+ # there is a violation to record.
53
+ by_group = bucket(errors, :minimum_coverage_by_group)
54
+ violations.each do |violation|
55
+ group_bucket = by_group[violation.fetch(:group_name)] ||= {} #: Hash[untyped, untyped]
56
+ group_bucket[key_for(violation)] = expected_actual(violation)
57
+ end
60
58
  end
61
- end
62
59
 
63
- def format_maximum_overall
64
- SimpleCov::CoverageViolations.maximum_overall(@result, SimpleCov.maximum_coverage).each do |violation|
65
- bucket(:maximum_coverage)[key_for(violation)] = expected_actual(violation)
60
+ def format_maximum_overall(result, errors)
61
+ SimpleCov::CoverageViolations.maximum_overall(result, SimpleCov.maximum_coverage).each do |violation|
62
+ bucket(errors, :maximum_coverage)[key_for(violation)] = expected_actual(violation)
63
+ end
66
64
  end
67
- end
68
65
 
69
- def format_maximum_drop
70
- SimpleCov::CoverageViolations.maximum_drop(@result, SimpleCov.maximum_coverage_drop).each do |violation|
71
- bucket(:maximum_coverage_drop)[key_for(violation)] =
72
- {maximum: violation.fetch(:maximum), actual: violation.fetch(:actual)}
66
+ def format_maximum_drop(result, errors)
67
+ SimpleCov::CoverageViolations.maximum_drop(result, SimpleCov.maximum_coverage_drop).each do |violation|
68
+ bucket(errors, :maximum_coverage_drop)[key_for(violation)] =
69
+ {maximum: violation.fetch(:maximum), actual: violation.fetch(:actual)}
70
+ end
73
71
  end
74
- end
75
72
 
76
- def bucket(name)
77
- @errors[name] ||= {} #: Hash[untyped, untyped]
78
- end
73
+ def bucket(errors, name)
74
+ errors[name] ||= {} #: Hash[untyped, untyped]
75
+ end
79
76
 
80
- def key_for(violation)
81
- CRITERION_KEYS.fetch(SimpleCov.coverage_statistics_key(violation.fetch(:criterion)))
82
- end
77
+ def key_for(violation)
78
+ CRITERION_KEYS.fetch(SimpleCov.coverage_statistics_key(violation.fetch(:criterion)))
79
+ end
83
80
 
84
- def expected_actual(violation)
85
- {expected: violation.fetch(:expected), actual: violation.fetch(:actual)}
81
+ def expected_actual(violation)
82
+ {expected: violation.fetch(:expected), actual: violation.fetch(:actual)}
83
+ end
86
84
  end
87
85
  end
88
86
  end
@@ -22,105 +22,95 @@ module SimpleCov
22
22
  SCHEMA_URL = "https://raw.githubusercontent.com/simplecov-ruby/simplecov/main/schemas/coverage-v#{SCHEMA_VERSION}.schema.json".freeze
23
23
  private_constant :SCHEMA_VERSION, :SCHEMA_URL
24
24
 
25
- def initialize(result, include_source: true)
26
- @result = result
27
- @include_source = include_source
28
- end
29
-
30
- def format
31
- {
32
- :$schema => SCHEMA_URL,
33
- :meta => format_meta,
34
- :total => format_coverage_statistics(@result.coverage_statistics),
35
- :coverage => format_files,
36
- :groups => format_groups,
37
- :errors => ErrorsFormatter.new(@result).call
38
- }
39
- end
25
+ class << self
26
+ def format(result, include_source: true)
27
+ {
28
+ :$schema => SCHEMA_URL,
29
+ :meta => format_meta(result),
30
+ :total => format_coverage_statistics(result.coverage_statistics),
31
+ :coverage => format_files(result, include_source: include_source),
32
+ :groups => format_groups(result),
33
+ :errors => ErrorsFormatter.call(result)
34
+ }
35
+ end
40
36
 
41
- private
37
+ private
42
38
 
43
- def format_files
44
- @result.files.to_h do |source_file|
45
- [source_file.project_filename, SourceFileFormatter.new(source_file, include_source: @include_source).call]
39
+ def format_files(result, include_source:)
40
+ result.files.to_h do |source_file|
41
+ [source_file.project_filename, SourceFileFormatter.call(source_file, include_source: include_source)]
42
+ end
46
43
  end
47
- end
48
44
 
49
- def format_groups
50
- @result.groups.to_h do |name, file_list|
51
- stats = format_coverage_statistics(file_list.coverage_statistics)
52
- [name, stats.merge(files: file_list.map(&:project_filename))]
45
+ def format_groups(result)
46
+ result.groups.to_h do |name, file_list|
47
+ stats = format_coverage_statistics(file_list.coverage_statistics)
48
+ [name, stats.merge(files: file_list.map(&:project_filename))]
49
+ end
53
50
  end
54
- end
55
-
56
- def format_meta
57
- {
58
- schema_version: SCHEMA_VERSION,
59
- simplecov_version: SimpleCov::VERSION,
60
- command_name: @result.command_name,
61
- project_name: SimpleCov.project_name,
62
- timestamp: @result.created_at.iso8601(3),
63
- root: SimpleCov.root,
64
- commit: git_commit,
65
- primary_coverage: SimpleCov.primary_coverage.to_s
66
- }.merge!(coverage_flags)
67
- end
68
51
 
69
- # Full git commit SHA of `SimpleCov.root`'s HEAD, or nil when the
70
- # project isn't a git checkout or git isn't on PATH. Recorded so tools
71
- # can recover the exact source a report was generated against, which
72
- # matters most when `source_in_json false` drops the source text from
73
- # coverage.json. stderr is captured (not forwarded) so a non-git project
74
- # doesn't print git's diagnostics to the build.
75
- def git_commit
76
- output, status = Open3.capture2e("git", "-C", SimpleCov.root.to_s, "rev-parse", "HEAD")
77
- status.success? ? output.strip : nil
78
- rescue StandardError
79
- nil
80
- end
52
+ def format_meta(result)
53
+ {
54
+ schema_version: SCHEMA_VERSION,
55
+ simplecov_version: SimpleCov::VERSION,
56
+ command_name: result.command_name,
57
+ project_name: SimpleCov.project_name,
58
+ timestamp: result.created_at.iso8601(3),
59
+ root: SimpleCov.root,
60
+ commit: git_commit,
61
+ primary_coverage: SimpleCov.primary_coverage.to_s
62
+ }.merge!(coverage_flags)
63
+ end
81
64
 
82
- def coverage_flags
83
- {
84
- line_coverage: line_coverage_enabled?,
85
- branch_coverage: SimpleCov.branch_coverage?,
86
- method_coverage: SimpleCov.method_coverage?
87
- }
88
- end
65
+ # Full git commit SHA of `SimpleCov.root`'s HEAD, or nil when the
66
+ # project isn't a git checkout or git isn't on PATH. Recorded so tools
67
+ # can recover the exact source a report was generated against, which
68
+ # matters most when `source_in_json false` drops the source text from
69
+ # coverage.json. stderr is captured (not forwarded) so a non-git project
70
+ # doesn't print git's diagnostics to the build.
71
+ def git_commit
72
+ output, status = Open3.capture2e("git", "-C", SimpleCov.root.to_s, "rev-parse", "HEAD")
73
+ status.success? ? output.strip : nil
74
+ rescue StandardError
75
+ nil
76
+ end
89
77
 
90
- # Mirrors SourceFileFormatter's predicate so meta.line_coverage
91
- # tracks exactly which configurations cause the formatter to
92
- # emit line stats.
93
- def line_coverage_enabled?
94
- SimpleCov.coverage_criterion_enabled?(:line) || SimpleCov.coverage_criterion_enabled?(:oneshot_line)
95
- end
78
+ def coverage_flags
79
+ {
80
+ line_coverage: SimpleCov.line_coverage?,
81
+ branch_coverage: SimpleCov.branch_coverage?,
82
+ method_coverage: SimpleCov.method_coverage?
83
+ }
84
+ end
96
85
 
97
- def format_coverage_statistics(statistics)
98
- result = {} #: Hash[Symbol, untyped]
99
- result[:lines] = format_line_statistic(statistics[:line]) if statistics[:line]
100
- result[:branches] = format_single_statistic(statistics[:branch]) if statistics[:branch]
101
- result[:methods] = format_single_statistic(statistics[:method]) if statistics[:method]
102
- result
103
- end
86
+ def format_coverage_statistics(statistics)
87
+ result = {} #: Hash[Symbol, untyped]
88
+ result[:lines] = format_line_statistic(statistics[:line]) if statistics[:line]
89
+ result[:branches] = format_single_statistic(statistics[:branch]) if statistics[:branch]
90
+ result[:methods] = format_single_statistic(statistics[:method]) if statistics[:method]
91
+ result
92
+ end
104
93
 
105
- def format_line_statistic(stat)
106
- {
107
- covered: stat.covered,
108
- missed: stat.missed,
109
- omitted: stat.omitted,
110
- total: stat.total,
111
- percent: stat.percent,
112
- strength: stat.strength
113
- }
114
- end
94
+ def format_line_statistic(stat)
95
+ {
96
+ covered: stat.covered,
97
+ missed: stat.missed,
98
+ omitted: stat.omitted,
99
+ total: stat.total,
100
+ percent: stat.percent,
101
+ strength: stat.strength
102
+ }
103
+ end
115
104
 
116
- def format_single_statistic(stat)
117
- {
118
- covered: stat.covered,
119
- missed: stat.missed,
120
- total: stat.total,
121
- percent: stat.percent,
122
- strength: stat.strength
123
- }
105
+ def format_single_statistic(stat)
106
+ {
107
+ covered: stat.covered,
108
+ missed: stat.missed,
109
+ total: stat.total,
110
+ percent: stat.percent,
111
+ strength: stat.strength
112
+ }
113
+ end
124
114
  end
125
115
  end
126
116
  end
@@ -7,91 +7,85 @@ module SimpleCov
7
7
  # in coverage.json: source code plus per-enabled-criterion arrays
8
8
  # and totals.
9
9
  class SourceFileFormatter
10
- def initialize(source_file, include_source: true)
11
- @source_file = source_file
12
- @include_source = include_source
13
- end
14
-
15
- def call
16
- result = @include_source ? format_source_code : {} #: Hash[Symbol, untyped]
17
- result.merge!(line_coverage_section) if line_coverage_enabled?
18
- result.merge!(branch_coverage_section) if SimpleCov.branch_coverage?
19
- result.merge!(method_coverage_section) if SimpleCov.method_coverage?
20
- result
21
- end
10
+ class << self
11
+ def call(source_file, include_source: true)
12
+ result = include_source ? format_source_code(source_file) : {} #: Hash[Symbol, untyped]
13
+ result.merge!(line_coverage_section(source_file)) if SimpleCov.line_coverage?
14
+ result.merge!(branch_coverage_section(source_file)) if SimpleCov.branch_coverage?
15
+ result.merge!(method_coverage_section(source_file)) if SimpleCov.method_coverage?
16
+ result
17
+ end
22
18
 
23
- private
19
+ private
24
20
 
25
- # `:oneshot_line` is a synonym for `:line` for stats purposes
26
- # (see `SimpleCov.coverage_statistics_key`), so treat either as
27
- # "line coverage is on" for the line-block emit decisions.
28
- def line_coverage_enabled?
29
- SimpleCov.coverage_criterion_enabled?(:line) || SimpleCov.coverage_criterion_enabled?(:oneshot_line)
30
- end
31
-
32
- def format_source_code
33
- {source: @source_file.lines.map { |line| ensure_utf8(line.src.chomp) }}
34
- end
21
+ # No per-line encoding conversion here: SourceLoader guarantees
22
+ # every line leaves it as valid UTF-8 (transcoding declared
23
+ # encodings, scrubbing invalid bytes), and the converter copy
24
+ # this used to make per line was the single largest allocation
25
+ # source in formatting a large report.
26
+ def format_source_code(source_file)
27
+ {source: source_file.lines.map { |line| line.src.chomp }}
28
+ end
35
29
 
36
- def ensure_utf8(str)
37
- str.encode("UTF-8", invalid: :replace, undef: :replace)
38
- end
30
+ def line_coverage_section(source_file)
31
+ covered = source_file.covered_lines.size
32
+ missed = source_file.missed_lines.size
33
+ {
34
+ lines: source_file.lines.map { |line| format_line(line) },
35
+ lines_covered_percent: source_file.covered_percent,
36
+ covered_lines: covered,
37
+ missed_lines: missed,
38
+ omitted_lines: source_file.never_lines.size,
39
+ total_lines: covered + missed
40
+ }
41
+ end
39
42
 
40
- def line_coverage_section
41
- covered = @source_file.covered_lines.size
42
- missed = @source_file.missed_lines.size
43
- {
44
- lines: @source_file.lines.map { |line| format_line(line) },
45
- lines_covered_percent: @source_file.covered_percent,
46
- covered_lines: covered,
47
- missed_lines: missed,
48
- omitted_lines: @source_file.never_lines.size,
49
- total_lines: covered + missed
50
- }
51
- end
43
+ def branch_coverage_section(source_file)
44
+ {
45
+ branches: source_file.branches.map { |branch| format_branch(branch) },
46
+ branches_covered_percent: source_file.covered_percent(:branch),
47
+ covered_branches: source_file.covered_branches.size,
48
+ missed_branches: source_file.missed_branches.size,
49
+ total_branches: source_file.total_branches.size
50
+ }
51
+ end
52
52
 
53
- def branch_coverage_section
54
- {
55
- branches: @source_file.branches.map { |branch| format_branch(branch) },
56
- branches_covered_percent: @source_file.covered_percent(:branch),
57
- covered_branches: @source_file.covered_branches.size,
58
- missed_branches: @source_file.missed_branches.size,
59
- total_branches: @source_file.total_branches.size
60
- }
61
- end
53
+ def method_coverage_section(source_file)
54
+ covered = source_file.covered_methods.size
55
+ missed = source_file.missed_methods.size
62
56
 
63
- def method_coverage_section
64
- {
65
- methods: @source_file.methods.map { |method| format_method(method) },
66
- methods_covered_percent: @source_file.covered_percent(:method),
67
- covered_methods: @source_file.covered_methods.size,
68
- missed_methods: @source_file.missed_methods.size,
69
- total_methods: @source_file.methods.size
70
- }
71
- end
57
+ {
58
+ methods: source_file.methods.map { |method| format_method(method) },
59
+ methods_covered_percent: source_file.covered_percent(:method),
60
+ covered_methods: covered,
61
+ missed_methods: missed,
62
+ total_methods: covered + missed
63
+ }
64
+ end
72
65
 
73
- def format_line(line)
74
- line.skipped? ? "ignored" : line.coverage
75
- end
66
+ def format_line(line)
67
+ line.skipped? ? "ignored" : line.coverage
68
+ end
76
69
 
77
- def format_branch(branch)
78
- {
79
- type: branch.type,
80
- start_line: branch.start_line,
81
- end_line: branch.end_line,
82
- coverage: format_line(branch),
83
- inline: branch.inline?,
84
- report_line: branch.report_line
85
- }
86
- end
70
+ def format_branch(branch)
71
+ {
72
+ type: branch.type,
73
+ start_line: branch.start_line,
74
+ end_line: branch.end_line,
75
+ coverage: format_line(branch),
76
+ inline: branch.inline?,
77
+ report_line: branch.report_line
78
+ }
79
+ end
87
80
 
88
- def format_method(method)
89
- {
90
- name: method.to_s,
91
- start_line: method.start_line,
92
- end_line: method.end_line,
93
- coverage: format_line(method)
94
- }
81
+ def format_method(method)
82
+ {
83
+ name: method.to_s,
84
+ start_line: method.start_line,
85
+ end_line: method.end_line,
86
+ coverage: format_line(method)
87
+ }
88
+ end
95
89
  end
96
90
  end
97
91
  end
@@ -1,9 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
- require "fileutils"
4
+ require_relative "coverage_json_writer"
5
5
  require "json"
6
- require "time"
7
6
 
8
7
  module SimpleCov
9
8
  module Formatter
@@ -11,7 +10,7 @@ module SimpleCov
11
10
  # standalone, alongside the HTML formatter, or by external tools that
12
11
  # consume SimpleCov output.
13
12
  class JSONFormatter < Base
14
- FILENAME = "coverage.json"
13
+ FILENAME = CoverageJSONWriter::FILENAME
15
14
 
16
15
  # `include_source:` defaults to `SimpleCov.source_in_json` (true
17
16
  # by default) so the historical payload shape is unchanged.
@@ -19,19 +18,12 @@ module SimpleCov
19
18
  # setting (the HTML formatter, which feeds the client-side
20
19
  # viewer) pass `include_source: true` explicitly.
21
20
  def self.build_hash(result, include_source: SimpleCov.source_in_json)
22
- ResultHashFormatter.new(result, include_source: include_source).format
21
+ ResultHashFormatter.format(result, include_source: include_source)
23
22
  end
24
23
 
25
24
  def format(result)
26
- FileUtils.mkdir_p(output_path)
27
- path = File.join(output_path, FILENAME)
28
- warn_if_concurrent_overwrite(path, result)
29
- File.write(path, JSON.pretty_generate(self.class.build_hash(result)))
30
- # stderr, not stdout: this is a status message, not the program's
31
- # output. Keeps the line out of pipelines like `rspec -f json`. And
32
- # $stderr.puts, not `warn`: a status line should not reach
33
- # `Warning.warn` hooks or vanish under `-W0` (see #1225).
34
- $stderr.puts output_message(result) unless @silent # rubocop:disable Style/StderrPuts
25
+ CoverageJSONWriter.write(output_path, self.class.build_hash(result), result)
26
+ emit_status(result)
35
27
  end
36
28
 
37
29
  private
@@ -43,41 +35,6 @@ module SimpleCov
43
35
  def entry_point_filename
44
36
  FILENAME
45
37
  end
46
-
47
- # Warns when the existing coverage.json has a timestamp newer than this
48
- # process's start time — a strong signal that a sibling test process
49
- # (e.g., parallel_tests) wrote it while we were running, and that our
50
- # write is about to clobber their data.
51
- def warn_if_concurrent_overwrite(path, result)
52
- start_time = SimpleCov.process_start_time or return
53
- existing = existing_meta(path) or return
54
- return unless existing[:timestamp] > start_time
55
-
56
- # The HTML formatter also writes coverage.json (it shares the file as
57
- # a side artifact), so when both formatters are configured the file we
58
- # find was just written by our own run, not a concurrent one. A
59
- # matching command_name means the same merged result, so there's
60
- # nothing to lose by overwriting. See issue #1171.
61
- return if existing[:command_name] == result.command_name
62
-
63
- warn "simplecov: #{path} was written at #{existing[:timestamp].iso8601} — after " \
64
- "this process started at #{start_time.iso8601}. Overwriting " \
65
- "likely loses coverage data from a concurrent test run. For " \
66
- "parallel test setups, use SimpleCov::ResultMerger or run a single " \
67
- "collation step after all workers finish."
68
- end
69
-
70
- def existing_meta(path)
71
- return nil unless File.exist?(path)
72
-
73
- meta = JSON.parse(File.read(path), symbolize_names: true)
74
- timestamp = meta.dig(:meta, :timestamp)
75
- return nil unless timestamp
76
-
77
- {timestamp: Time.iso8601(timestamp), command_name: meta.dig(:meta, :command_name)}
78
- rescue JSON::ParserError, ArgumentError
79
- nil
80
- end
81
38
  end
82
39
  end
83
40
  end
@@ -10,7 +10,7 @@ module SimpleCov
10
10
  module InstanceMethods
11
11
  def format(result)
12
12
  formatters.map do |formatter|
13
- Formatter.instance_for(formatter).format(result)
13
+ Formatter.format(formatter, result)
14
14
  rescue StandardError => e
15
15
  warn("Formatter #{formatter} failed with #{e.class}: #{e.message} (#{(_ = e.backtrace).first})")
16
16
  nil
@@ -8,16 +8,19 @@ module SimpleCov
8
8
  class SimpleFormatter
9
9
  # Takes a SimpleCov::Result and generates a string out of it
10
10
  def format(result)
11
- result.groups.map { |name, files| format_group(name, files) }.join
11
+ criterion = SimpleCov.coverage_statistics_key(SimpleCov.primary_coverage)
12
+ result.groups.map { |name, files| format_group(name, files, criterion) }.join
12
13
  end
13
14
 
14
15
  private
15
16
 
16
- def format_group(name, files)
17
+ def format_group(name, files, criterion)
17
18
  header = "Group: #{name}\n#{'=' * 40}\n"
18
- # `covered_percent` is nilable across criteria, but line stats are
19
- # always measured, so the no-argument call can't return nil here.
20
- body = files.map { |file| "#{file.filename} (coverage: #{(_ = file.covered_percent).floor(2)}%)\n" }.join
19
+ # The configured primary criterion is enabled, so every result file
20
+ # carries its corresponding statistic despite the nilable public API.
21
+ body = files.map do |file|
22
+ "#{file.filename} (coverage: #{(_ = file.covered_percent(criterion)).floor(2)}%)\n"
23
+ end.join
21
24
  "#{header}#{body}\n"
22
25
  end
23
26
  end
@@ -13,6 +13,11 @@ module SimpleCov
13
13
  def self.instance_for(formatter)
14
14
  formatter.is_a?(Class) ? formatter.new : formatter
15
15
  end
16
+
17
+ # Normalize a class or instance, then dispatch the result to it.
18
+ def self.format(formatter, result)
19
+ instance_for(formatter).format(result)
20
+ end
16
21
  end
17
22
  end
18
23
 
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ # Semantic names owned by result processing rather than user configuration.
5
+ module GroupNames
6
+ UNGROUPED = "Ungrouped"
7
+
8
+ module_function
9
+
10
+ # Group names are Hash keys, report labels, and JSON object keys all at
11
+ # once, so they are normalized to Strings up front: a Symbol spelling
12
+ # (`group :Controllers`) means the String, and anything else has no
13
+ # sensible serialized form. Normalizing before `validate!` also keeps
14
+ # `group :Ungrouped` from slipping past the reservation below.
15
+ def normalize(group_name)
16
+ case group_name
17
+ when String then group_name
18
+ when Symbol then group_name.to_s
19
+ else
20
+ raise SimpleCov::ConfigurationError,
21
+ "Group names must be Strings, got #{group_name.inspect} (#{group_name.class})"
22
+ end
23
+ end
24
+
25
+ def validate!(group_names)
26
+ return group_names unless group_names.include?(UNGROUPED)
27
+
28
+ raise SimpleCov::ConfigurationError,
29
+ "#{UNGROUPED.inspect} is reserved for files that do not match a configured group"
30
+ end
31
+ end
32
+ end