simplecov 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +165 -57
- data/exe/simplecov +0 -3
- data/lib/minitest/simplecov_plugin.rb +12 -9
- data/lib/simplecov/atomic_file.rb +16 -19
- data/lib/simplecov/autostart.rb +5 -7
- data/lib/simplecov/baseline/parser.rb +58 -0
- data/lib/simplecov/baseline.rb +170 -0
- data/lib/simplecov/cli/affected/changed_files.rb +57 -0
- data/lib/simplecov/cli/affected/selection.rb +117 -0
- data/lib/simplecov/cli/affected.rb +148 -0
- data/lib/simplecov/cli/badge/svg.rb +70 -0
- data/lib/simplecov/cli/badge.rb +62 -0
- data/lib/simplecov/cli/clean.rb +23 -24
- data/lib/simplecov/cli/command_helpers.rb +55 -19
- data/lib/simplecov/cli/completions/scripts.rb +114 -0
- data/lib/simplecov/cli/completions.rb +83 -0
- data/lib/simplecov/cli/coverage.rb +25 -28
- data/lib/simplecov/cli/coverage_file.rb +58 -14
- data/lib/simplecov/cli/dead_code/output.rb +100 -0
- data/lib/simplecov/cli/dead_code.rb +139 -0
- data/lib/simplecov/cli/diff/output.rb +45 -0
- data/lib/simplecov/cli/diff.rb +35 -67
- data/lib/simplecov/cli/dotfile.rb +83 -38
- data/lib/simplecov/cli/git.rb +41 -0
- data/lib/simplecov/cli/history/output.rb +135 -0
- data/lib/simplecov/cli/history.rb +67 -0
- data/lib/simplecov/cli/merge.rb +34 -39
- data/lib/simplecov/cli/open.rb +12 -15
- data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
- data/lib/simplecov/cli/patch/output.rb +135 -0
- data/lib/simplecov/cli/patch.rb +169 -0
- data/lib/simplecov/cli/ratchet/output.rb +44 -0
- data/lib/simplecov/cli/ratchet.rb +83 -0
- data/lib/simplecov/cli/report.rb +18 -24
- data/lib/simplecov/cli/run.rb +8 -9
- data/lib/simplecov/cli/serve/report_preparer.rb +7 -6
- data/lib/simplecov/cli/serve/static_file_handler.rb +62 -56
- data/lib/simplecov/cli/serve.rb +27 -24
- data/lib/simplecov/cli/show/annotator.rb +87 -0
- data/lib/simplecov/cli/show/sweep.rb +36 -0
- data/lib/simplecov/cli/show.rb +133 -0
- data/lib/simplecov/cli/status/facts.rb +87 -0
- data/lib/simplecov/cli/status.rb +93 -0
- data/lib/simplecov/cli/tests/redundancy.rb +78 -0
- data/lib/simplecov/cli/tests.rb +148 -0
- data/lib/simplecov/cli/uncovered/misses.rb +37 -0
- data/lib/simplecov/cli/uncovered.rb +70 -32
- data/lib/simplecov/cli/usage.rb +152 -0
- data/lib/simplecov/cli/watch/live_report.rb +73 -0
- data/lib/simplecov/cli/watch/narrator.rb +55 -0
- data/lib/simplecov/cli/watch/poller.rb +45 -0
- data/lib/simplecov/cli/watch/session.rb +134 -0
- data/lib/simplecov/cli/watch/test_plan.rb +43 -0
- data/lib/simplecov/cli/watch.rb +85 -0
- data/lib/simplecov/cli.rb +43 -92
- data/lib/simplecov/color.rb +21 -35
- data/lib/simplecov/combine/branches_combiner.rb +7 -31
- data/lib/simplecov/combine/coverage_accumulator.rb +49 -142
- data/lib/simplecov/combine/identity_interner.rb +10 -13
- data/lib/simplecov/combine/interned_counts.rb +7 -9
- data/lib/simplecov/combine/lines_combiner.rb +20 -44
- data/lib/simplecov/combine/methods_combiner.rb +9 -35
- data/lib/simplecov/combine/results_combiner.rb +4 -21
- data/lib/simplecov/combine.rb +0 -6
- data/lib/simplecov/command_guesser.rb +20 -34
- data/lib/simplecov/configuration/baseline.rb +31 -0
- data/lib/simplecov/configuration/coverage.rb +91 -79
- data/lib/simplecov/configuration/coverage_criteria.rb +43 -54
- data/lib/simplecov/configuration/deprecations.rb +21 -0
- data/lib/simplecov/configuration/eval_coverage.rb +6 -10
- data/lib/simplecov/configuration/filters.rb +40 -73
- data/lib/simplecov/configuration/formatting.rb +74 -83
- data/lib/simplecov/configuration/groups.rb +19 -13
- data/lib/simplecov/configuration/history.rb +44 -0
- data/lib/simplecov/configuration/ignored_entries.rb +31 -22
- data/lib/simplecov/configuration/merging.rb +94 -62
- data/lib/simplecov/configuration/missed_caps.rb +51 -0
- data/lib/simplecov/configuration/production.rb +23 -0
- data/lib/simplecov/configuration/test_tracking.rb +48 -0
- data/lib/simplecov/configuration/thresholds.rb +33 -62
- data/lib/simplecov/configuration/view_coverage.rb +38 -0
- data/lib/simplecov/configuration.rb +70 -76
- data/lib/simplecov/configuration_error.rb +5 -0
- data/lib/simplecov/context_map/union.rb +81 -0
- data/lib/simplecov/context_map.rb +187 -0
- data/lib/simplecov/coverage_json.rb +2 -5
- data/lib/simplecov/coverage_statistics.rb +14 -24
- data/lib/simplecov/coverage_violations.rb +157 -64
- data/lib/simplecov/current_run.rb +61 -0
- data/lib/simplecov/defaults.rb +4 -23
- data/lib/simplecov/deprecation.rb +45 -27
- data/lib/simplecov/directive.rb +46 -62
- data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
- data/lib/simplecov/exit_codes/check.rb +17 -11
- data/lib/simplecov/exit_codes/exit_code_handling.rb +21 -8
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +7 -10
- data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
- data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +7 -12
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +11 -10
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +7 -9
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +16 -17
- data/lib/simplecov/exit_codes.rb +17 -7
- data/lib/simplecov/exit_handling.rb +94 -59
- data/lib/simplecov/file_list.rb +20 -51
- data/lib/simplecov/filter.rb +43 -66
- data/lib/simplecov/formatter/base.rb +38 -51
- data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
- data/lib/simplecov/formatter/coverage_json_writer.rb +29 -31
- data/lib/simplecov/formatter/html_formatter/public/index.html +17 -10
- data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +74 -17
- data/lib/simplecov/formatter/html_formatter.rb +24 -41
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +50 -16
- data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +43 -29
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +14 -10
- data/lib/simplecov/formatter/json_formatter.rb +7 -13
- data/lib/simplecov/formatter/multi_formatter.rb +5 -11
- data/lib/simplecov/formatter/simple_formatter.rb +2 -8
- data/lib/simplecov/formatter.rb +14 -10
- data/lib/simplecov/group_names.rb +13 -12
- data/lib/simplecov/history.rb +115 -0
- data/lib/simplecov/last_run.rb +6 -9
- data/lib/simplecov/lines_classifier.rb +31 -16
- data/lib/simplecov/load_global_config.rb +7 -7
- data/lib/simplecov/parallel_adapters/base.rb +13 -41
- data/lib/simplecov/parallel_adapters/generic.rb +7 -18
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
- data/lib/simplecov/parallel_adapters.rb +24 -53
- data/lib/simplecov/parallel_coordination.rb +39 -49
- data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
- data/lib/simplecov/parallel_result_merger.rb +67 -126
- data/lib/simplecov/process.rb +14 -32
- data/lib/simplecov/production/error.rb +11 -0
- data/lib/simplecov/production/file_sink.rb +130 -0
- data/lib/simplecov/production.rb +249 -0
- data/lib/simplecov/profiles/rails.rb +1 -12
- data/lib/simplecov/profiles/root_filter.rb +0 -4
- data/lib/simplecov/profiles/strict.rb +9 -19
- data/lib/simplecov/profiles.rb +11 -33
- data/lib/simplecov/report_deferral.rb +11 -15
- data/lib/simplecov/report_stamp.rb +6 -9
- data/lib/simplecov/result/filter_config.rb +19 -0
- data/lib/simplecov/result/missing_source_files_reporter.rb +8 -9
- data/lib/simplecov/result/serialization.rb +43 -0
- data/lib/simplecov/result/source_file_builder.rb +7 -12
- data/lib/simplecov/result.rb +68 -117
- data/lib/simplecov/result_adapter.rb +58 -68
- data/lib/simplecov/result_merger/contexts.rb +22 -0
- data/lib/simplecov/result_merger/legacy_format_adapter.rb +4 -8
- data/lib/simplecov/result_merger/resultset_file.rb +20 -27
- data/lib/simplecov/result_merger/resultset_run_identity.rb +23 -25
- data/lib/simplecov/result_merger/resultset_store.rb +23 -14
- data/lib/simplecov/result_merger/unloaded_files.rb +24 -32
- data/lib/simplecov/result_merger.rb +59 -58
- data/lib/simplecov/result_processing.rb +74 -119
- data/lib/simplecov/run_identity.rb +16 -18
- data/lib/simplecov/simulate_coverage.rb +35 -50
- data/lib/simplecov/source_file/branch.rb +8 -33
- data/lib/simplecov/source_file/branch_builder.rb +28 -47
- data/lib/simplecov/source_file/builder_context.rb +6 -13
- data/lib/simplecov/source_file/line.rb +13 -33
- data/lib/simplecov/source_file/line_builder.rb +9 -15
- data/lib/simplecov/source_file/method.rb +3 -7
- data/lib/simplecov/source_file/method_builder.rb +6 -15
- data/lib/simplecov/source_file/ruby_data_parser.rb +59 -66
- data/lib/simplecov/source_file/skip_chunks.rb +19 -28
- data/lib/simplecov/source_file/source_loader.rb +45 -35
- data/lib/simplecov/source_file/statistics.rb +10 -9
- data/lib/simplecov/source_file.rb +21 -51
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +63 -114
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
- data/lib/simplecov/static_coverage_extractor/method_collector.rb +11 -24
- data/lib/simplecov/static_coverage_extractor/prism_compat.rb +15 -24
- data/lib/simplecov/static_coverage_extractor/value_position.rb +25 -38
- data/lib/simplecov/static_coverage_extractor/visitor.rb +47 -64
- data/lib/simplecov/static_coverage_extractor.rb +46 -62
- data/lib/simplecov/test_tracker/accessors.rb +34 -0
- data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
- data/lib/simplecov/test_tracker/delta.rb +69 -0
- data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
- data/lib/simplecov/test_tracker.rb +162 -0
- data/lib/simplecov/unloaded_file_injector.rb +18 -31
- data/lib/simplecov/useless_results_remover.rb +7 -6
- data/lib/simplecov/version.rb +1 -1
- data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
- data/lib/simplecov/view_coverage.rb +55 -0
- data/lib/simplecov-html.rb +1 -0
- data/lib/simplecov.rb +59 -114
- data/lib/simplecov_json_formatter.rb +1 -0
- data/man/simplecov.1 +349 -0
- data/sig/simplecov.rbs +523 -595
- metadata +72 -7
- data/schemas/coverage-v1.0.schema.json +0 -306
- data/schemas/coverage.schema.json +0 -306
|
@@ -5,8 +5,6 @@ require "time"
|
|
|
5
5
|
module SimpleCov
|
|
6
6
|
module Formatter
|
|
7
7
|
class HTMLFormatter
|
|
8
|
-
# Validates the subset of coverage.json that the browser viewer
|
|
9
|
-
# dereferences without defensive fallbacks.
|
|
10
8
|
module ViewerDataValidator
|
|
11
9
|
META_STRINGS = %w[simplecov_version command_name project_name timestamp].freeze
|
|
12
10
|
COVERAGE_FLAGS = {
|
|
@@ -25,36 +23,95 @@ module SimpleCov
|
|
|
25
23
|
validate_statistics!(data.fetch("total"), meta, "total")
|
|
26
24
|
data.fetch("coverage").each { |filename, file| validate_file!(filename, file) }
|
|
27
25
|
data.fetch("groups").each { |name, group| validate_group!(name, group, meta) }
|
|
26
|
+
validate_contexts!(data)
|
|
27
|
+
validate_production!(data)
|
|
28
28
|
data
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
private
|
|
32
32
|
|
|
33
33
|
def validate_section!(data, key)
|
|
34
|
-
return if data[key].
|
|
34
|
+
return if data[key].instance_of?(Hash)
|
|
35
35
|
|
|
36
|
-
raise
|
|
36
|
+
raise CoverageJSON::Error, "#{key.inspect} must be an object"
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def validate_file!(filename, file)
|
|
40
|
-
unless file.
|
|
41
|
-
raise
|
|
40
|
+
unless file.instance_of?(Hash)
|
|
41
|
+
raise CoverageJSON::Error, "coverage entry #{filename.inspect} must be an object"
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
source = file["source"]
|
|
45
|
-
return if source.
|
|
45
|
+
return if source.instance_of?(Array) && source.all?(String)
|
|
46
46
|
|
|
47
|
-
raise
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
raise CoverageJSON::Error,
|
|
48
|
+
"coverage entry #{filename.inspect} must include an array of source strings; " \
|
|
49
|
+
"regenerate with source_in_json true"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_contexts!(data)
|
|
53
|
+
contexts = data["contexts"]
|
|
54
|
+
valid = contexts.nil? || (contexts.instance_of?(Array) && contexts.all?(String))
|
|
55
|
+
raise CoverageJSON::Error, '"contexts" must be an array of strings' unless valid
|
|
56
|
+
|
|
57
|
+
count = Array(contexts).size
|
|
58
|
+
data.fetch("coverage").each { |filename, file| validate_context_table!(filename, file["contexts"], count) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_context_table!(filename, table, count)
|
|
62
|
+
return if table.nil? || (table.instance_of?(Hash) && table.all? do |key, value|
|
|
63
|
+
context_entry?(key, value, count)
|
|
64
|
+
end)
|
|
65
|
+
|
|
66
|
+
raise CoverageJSON::Error,
|
|
67
|
+
"coverage entry #{filename.inspect} contexts must map recorded context indices to hex bitmaps"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def context_entry?(key, value, count)
|
|
71
|
+
key.instance_of?(String) && key.match?(/\A\d+\z/) && Integer(key) < count &&
|
|
72
|
+
value.instance_of?(String) && value.match?(/\A\h+\z/)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def validate_production!(data)
|
|
76
|
+
production = data["production"]
|
|
77
|
+
return if production.nil?
|
|
78
|
+
|
|
79
|
+
raise CoverageJSON::Error, '"production" must be an object' unless production.instance_of?(Hash)
|
|
80
|
+
|
|
81
|
+
files = validate_type!(production, "files", Hash, "production")
|
|
82
|
+
files.each { |filename, entry| validate_production_file!(filename, entry) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate_production_file!(filename, entry)
|
|
86
|
+
unless production_lines?(entry)
|
|
87
|
+
raise CoverageJSON::Error,
|
|
88
|
+
"production entry #{filename.inspect} must list sorted line numbers"
|
|
89
|
+
end
|
|
90
|
+
last_seen = entry["last_seen"]
|
|
91
|
+
return if last_seen.nil? || last_seen.instance_of?(String)
|
|
92
|
+
|
|
93
|
+
raise CoverageJSON::Error, "production entry #{filename.inspect} last_seen must be a string"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def production_lines?(entry)
|
|
97
|
+
lines = entry["lines"] if entry.instance_of?(Hash)
|
|
98
|
+
lines.instance_of?(Array) && lines.all? { |line| line.instance_of?(Integer) && line.positive? }
|
|
50
99
|
end
|
|
51
100
|
|
|
52
101
|
def validate_meta!(meta)
|
|
53
102
|
META_STRINGS.each { |key| validate_type!(meta, key, String, "meta") }
|
|
103
|
+
validate_command_names!(meta)
|
|
54
104
|
Time.iso8601(meta.fetch("timestamp"))
|
|
55
105
|
COVERAGE_FLAGS.each_key { |key| validate_boolean!(meta, key) }
|
|
56
106
|
rescue ArgumentError
|
|
57
|
-
raise
|
|
107
|
+
raise CoverageJSON::Error, "meta.timestamp must be an ISO 8601 date-time"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def validate_command_names!(meta)
|
|
111
|
+
names = meta["command_names"]
|
|
112
|
+
return if names.nil? || (names.instance_of?(Array) && names.all?(String))
|
|
113
|
+
|
|
114
|
+
raise CoverageJSON::Error, "meta.command_names must be an array of strings"
|
|
58
115
|
end
|
|
59
116
|
|
|
60
117
|
def validate_statistics!(statistics, meta, location)
|
|
@@ -67,11 +124,11 @@ module SimpleCov
|
|
|
67
124
|
end
|
|
68
125
|
|
|
69
126
|
def validate_group!(name, group, meta)
|
|
70
|
-
raise
|
|
127
|
+
raise CoverageJSON::Error, "group #{name.inspect} must be an object" unless group.instance_of?(Hash)
|
|
71
128
|
|
|
72
129
|
files = group["files"]
|
|
73
|
-
unless files.
|
|
74
|
-
raise
|
|
130
|
+
unless files.instance_of?(Array) && files.all?(String)
|
|
131
|
+
raise CoverageJSON::Error, "group #{name.inspect}.files must be an array of strings"
|
|
75
132
|
end
|
|
76
133
|
|
|
77
134
|
validate_statistics!(group, meta, "group #{name.inspect}")
|
|
@@ -81,13 +138,13 @@ module SimpleCov
|
|
|
81
138
|
value = object[key]
|
|
82
139
|
return value if value.is_a?(type)
|
|
83
140
|
|
|
84
|
-
raise
|
|
141
|
+
raise CoverageJSON::Error, "#{location}.#{key} must be a #{type.name.downcase}"
|
|
85
142
|
end
|
|
86
143
|
|
|
87
144
|
def validate_boolean!(meta, key)
|
|
88
145
|
return if [true, false].include?(meta[key])
|
|
89
146
|
|
|
90
|
-
raise
|
|
147
|
+
raise CoverageJSON::Error, "meta.#{key} must be a boolean"
|
|
91
148
|
end
|
|
92
149
|
end
|
|
93
150
|
end
|
|
@@ -10,57 +10,45 @@ require_relative "json_formatter"
|
|
|
10
10
|
module SimpleCov
|
|
11
11
|
module Formatter
|
|
12
12
|
# Generates a single self-contained HTML coverage report. The compiled
|
|
13
|
-
# template
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# A coverage.json is written alongside from the same in-memory hash.
|
|
13
|
+
# template already carries the viewer app's JS and CSS inline, so the report
|
|
14
|
+
# can be mailed, uploaded as one CI artifact, or opened from anywhere
|
|
15
|
+
# without sibling files. A coverage.json is written alongside from the same
|
|
16
|
+
# in-memory hash.
|
|
18
17
|
class HTMLFormatter < Base
|
|
19
|
-
# Placeholder in the compiled template where the report's data goes.
|
|
20
18
|
DATA_MARKER = "<!-- SIMPLECOV_COVERAGE_DATA -->"
|
|
21
19
|
|
|
22
|
-
# The sibling files a 1.0.0 through 1.0.3 report wrote next to
|
|
23
|
-
# index.html. They are stale the moment a single-file report lands
|
|
24
|
-
# on top of them — `simplecov serve` would happily keep serving the
|
|
25
|
-
# old coverage_data.js — so formatting removes the known names.
|
|
26
20
|
LEGACY_REPORT_FILES = %w[
|
|
27
21
|
coverage_data.js application.js application.css
|
|
28
22
|
favicon_green.png favicon_red.png favicon_yellow.png
|
|
29
23
|
].freeze
|
|
30
24
|
|
|
31
25
|
def format(result)
|
|
32
|
-
# The inlined report data feeds the client-side viewer, which
|
|
33
|
-
# renders source from the embedded array — it always needs
|
|
34
|
-
# `source`, regardless of `SimpleCov.source_in_json`. The
|
|
35
|
-
# side-file `coverage.json` honors the setting so downstream
|
|
36
|
-
# tools that read source from disk can opt into a smaller
|
|
37
|
-
# payload. The embedded copy is serialized compactly (pretty
|
|
38
|
-
# printing puts every element of every per-line coverage array
|
|
39
|
-
# on its own indented line, roughly doubling the payload);
|
|
40
|
-
# coverage.json stays pretty-printed for human readers.
|
|
41
26
|
viewer_hash = JSONFormatter.build_hash(result, include_source: true)
|
|
42
27
|
json_hash = SimpleCov.source_in_json ? viewer_hash : source_less_hash(viewer_hash)
|
|
43
28
|
write_report_files(json_hash, viewer_hash, result)
|
|
44
29
|
emit_status(result)
|
|
45
30
|
end
|
|
46
31
|
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
# pretty-printed) input and rejects invalid JSON here rather than
|
|
51
|
-
# embedding it and failing at view time.
|
|
32
|
+
# Generates HTML from a pre-existing coverage.json without a live Result or
|
|
33
|
+
# even a running test suite. The round-trip through parse/generate compacts
|
|
34
|
+
# the input and rejects invalid JSON here rather than at view time.
|
|
52
35
|
def format_from_json(json_path, output_dir)
|
|
53
|
-
data = ViewerDataValidator.call(
|
|
36
|
+
data = ViewerDataValidator.call(CoverageJSON.load(json_path))
|
|
54
37
|
json = JSON.generate(data)
|
|
55
38
|
AtomicFile.write(File.join(output_dir, "index.html"), render_report(json), binary: true)
|
|
56
39
|
end
|
|
57
40
|
|
|
58
|
-
|
|
41
|
+
private
|
|
59
42
|
|
|
60
43
|
def entry_point_filename
|
|
61
44
|
"index.html"
|
|
62
45
|
end
|
|
63
46
|
|
|
47
|
+
# The inlined report data feeds the client-side viewer, which renders source
|
|
48
|
+
# from the embedded array, so it always needs `source` regardless of
|
|
49
|
+
# `SimpleCov.source_in_json`. The side-file `coverage.json` honors the
|
|
50
|
+
# setting, and stays pretty-printed for human readers while the embedded
|
|
51
|
+
# copy is serialized compactly.
|
|
64
52
|
def source_less_hash(hash)
|
|
65
53
|
coverage = hash.fetch(:coverage).transform_values { |file| file.except(:source) }
|
|
66
54
|
hash.merge(coverage: coverage)
|
|
@@ -72,31 +60,26 @@ module SimpleCov
|
|
|
72
60
|
FileUtils.rm_f(LEGACY_REPORT_FILES.map { |name| File.join(output_path, name) })
|
|
73
61
|
end
|
|
74
62
|
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
63
|
+
# `<` is escaped as \u003c, valid JSON since `<` can only occur inside
|
|
64
|
+
# strings, so embedded source text containing "</script>" cannot terminate
|
|
65
|
+
# the surrounding element. Block forms keep gsub/sub from interpreting
|
|
66
|
+
# backslashes in the JSON as replacement-string back-references.
|
|
67
|
+
#
|
|
68
|
+
# The template is read as UTF-8 explicitly, not the locale's
|
|
69
|
+
# `Encoding.default_external`: substituting a UTF-8 payload into a template
|
|
70
|
+
# tagged US-ASCII raises Encoding::CompatibilityError.
|
|
81
71
|
def render_report(json)
|
|
82
|
-
# UTF-8 explicitly, not the locale's `Encoding.default_external`:
|
|
83
|
-
# the JSON payload is UTF-8, and substituting it into a template
|
|
84
|
-
# tagged US-ASCII (or a Windows code page) raises
|
|
85
|
-
# Encoding::CompatibilityError the moment either side carries a
|
|
86
|
-
# non-ASCII character.
|
|
87
72
|
template = File.read(File.join(public_dir, "index.html"), encoding: Encoding::UTF_8)
|
|
88
73
|
unless template.include?(DATA_MARKER)
|
|
89
74
|
raise "SimpleCov's HTML template is missing its #{DATA_MARKER.inspect} marker"
|
|
90
75
|
end
|
|
91
76
|
|
|
92
|
-
data_script = "<script>window.SIMPLECOV_DATA = #{json.gsub(
|
|
77
|
+
data_script = "<script>window.SIMPLECOV_DATA = #{json.gsub("<") { '\u003c' }};</script>"
|
|
93
78
|
template.sub(DATA_MARKER) { data_script }
|
|
94
79
|
end
|
|
95
80
|
|
|
96
81
|
def public_dir
|
|
97
|
-
#
|
|
98
|
-
# which can't happen for a file on disk) so the path is a String.
|
|
99
|
-
File.join(__dir__.to_s, "html_formatter/public/")
|
|
82
|
+
"#{__dir__}/html_formatter/public/"
|
|
100
83
|
end
|
|
101
84
|
end
|
|
102
85
|
end
|
|
@@ -3,11 +3,6 @@
|
|
|
3
3
|
module SimpleCov
|
|
4
4
|
module Formatter
|
|
5
5
|
class JSONFormatter
|
|
6
|
-
# Translates the threshold violations reported by
|
|
7
|
-
# `SimpleCov::CoverageViolations` into the `:errors` section of
|
|
8
|
-
# coverage.json. Each violation is keyed by criterion
|
|
9
|
-
# (`:lines` / `:branches` / `:methods`) so consumers can render
|
|
10
|
-
# per-criterion messages without re-deriving them.
|
|
11
6
|
class ErrorsFormatter
|
|
12
7
|
CRITERION_KEYS = {line: :lines, branch: :branches, method: :methods}.freeze
|
|
13
8
|
private_constant :CRITERION_KEYS
|
|
@@ -17,27 +12,45 @@ module SimpleCov
|
|
|
17
12
|
errors = {} #: Hash[Symbol, Hash[untyped, untyped]]
|
|
18
13
|
format_minimum_overall(result, errors)
|
|
19
14
|
format_minimum_by_file(result, errors)
|
|
15
|
+
format_baseline(result, errors)
|
|
20
16
|
format_minimum_by_group(result, errors)
|
|
21
17
|
format_maximum_overall(result, errors)
|
|
22
18
|
format_maximum_drop(result, errors)
|
|
19
|
+
format_maximum_missed(result, errors)
|
|
20
|
+
format_maximum_missed_per_file(result, errors)
|
|
23
21
|
errors
|
|
24
22
|
end
|
|
25
23
|
|
|
26
|
-
|
|
24
|
+
private
|
|
27
25
|
|
|
28
26
|
def format_minimum_overall(result, errors)
|
|
29
|
-
|
|
27
|
+
CoverageViolations.minimum_overall(result, SimpleCov.minimum_coverage).each do |violation|
|
|
30
28
|
bucket(errors, :minimum_coverage)[key_for(violation)] = expected_actual(violation)
|
|
31
29
|
end
|
|
32
30
|
end
|
|
33
31
|
|
|
34
32
|
def format_minimum_by_file(result, errors)
|
|
35
|
-
violations =
|
|
36
|
-
result, SimpleCov.minimum_coverage_by_file, SimpleCov.minimum_coverage_by_file_overrides
|
|
33
|
+
violations = CoverageViolations.minimum_by_file(
|
|
34
|
+
result, SimpleCov.minimum_coverage_by_file, SimpleCov.minimum_coverage_by_file_overrides,
|
|
35
|
+
baseline: SimpleCov.baseline
|
|
37
36
|
)
|
|
38
37
|
violations.each { |violation| record_by_file(violation, errors) }
|
|
39
38
|
end
|
|
40
39
|
|
|
40
|
+
# Baseline-floor violations keyed like minimum_coverage_by_file: filename,
|
|
41
|
+
# then criterion. The payload adds the two missed counts the floor was judged
|
|
42
|
+
# by; `allowed_missed` is absent for a percent-only floor.
|
|
43
|
+
def format_baseline(result, errors)
|
|
44
|
+
CoverageViolations.baseline(result, SimpleCov.baseline).each do |violation|
|
|
45
|
+
by_file = bucket(errors, :baseline)
|
|
46
|
+
file_bucket = by_file[violation.fetch(:project_filename)] ||= {} #: Hash[untyped, untyped]
|
|
47
|
+
payload = expected_actual(violation).merge(actual_missed: violation.fetch(:actual_missed))
|
|
48
|
+
allowed = violation.fetch(:allowed_missed)
|
|
49
|
+
payload[:allowed_missed] = allowed if allowed
|
|
50
|
+
file_bucket[key_for(violation)] = payload
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
41
54
|
def record_by_file(violation, errors)
|
|
42
55
|
by_file = bucket(errors, :minimum_coverage_by_file)
|
|
43
56
|
file_bucket = by_file[violation.fetch(:project_filename)] ||= {} #: Hash[untyped, untyped]
|
|
@@ -45,11 +58,9 @@ module SimpleCov
|
|
|
45
58
|
end
|
|
46
59
|
|
|
47
60
|
def format_minimum_by_group(result, errors)
|
|
48
|
-
violations =
|
|
61
|
+
violations = CoverageViolations.minimum_by_group(result, SimpleCov.minimum_coverage_by_group)
|
|
49
62
|
return if violations.empty?
|
|
50
63
|
|
|
51
|
-
# `bucket` lazily creates the errors key, so only touch it when
|
|
52
|
-
# there is a violation to record.
|
|
53
64
|
by_group = bucket(errors, :minimum_coverage_by_group)
|
|
54
65
|
violations.each do |violation|
|
|
55
66
|
group_bucket = by_group[violation.fetch(:group_name)] ||= {} #: Hash[untyped, untyped]
|
|
@@ -58,18 +69,37 @@ module SimpleCov
|
|
|
58
69
|
end
|
|
59
70
|
|
|
60
71
|
def format_maximum_overall(result, errors)
|
|
61
|
-
|
|
72
|
+
CoverageViolations.maximum_overall(result, SimpleCov.maximum_coverage).each do |violation|
|
|
62
73
|
bucket(errors, :maximum_coverage)[key_for(violation)] = expected_actual(violation)
|
|
63
74
|
end
|
|
64
75
|
end
|
|
65
76
|
|
|
66
77
|
def format_maximum_drop(result, errors)
|
|
67
|
-
|
|
68
|
-
bucket(errors, :maximum_coverage_drop)[key_for(violation)] =
|
|
69
|
-
{maximum: violation.fetch(:maximum), actual: violation.fetch(:actual)}
|
|
78
|
+
CoverageViolations.maximum_drop(result, SimpleCov.maximum_coverage_drop).each do |violation|
|
|
79
|
+
bucket(errors, :maximum_coverage_drop)[key_for(violation)] = maximum_actual(violation)
|
|
70
80
|
end
|
|
71
81
|
end
|
|
72
82
|
|
|
83
|
+
def format_maximum_missed(result, errors)
|
|
84
|
+
CoverageViolations.maximum_missed(result, SimpleCov.maximum_missed).each do |violation|
|
|
85
|
+
bucket(errors, :maximum_missed)[key_for(violation)] = maximum_actual(violation)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def format_maximum_missed_per_file(result, errors)
|
|
90
|
+
violations = CoverageViolations.maximum_missed_by_file(
|
|
91
|
+
result, SimpleCov.maximum_missed_per_file, SimpleCov.maximum_missed_per_file_overrides,
|
|
92
|
+
baseline: SimpleCov.baseline
|
|
93
|
+
)
|
|
94
|
+
violations.each do |violation|
|
|
95
|
+
by_file = bucket(errors, :maximum_missed_per_file)
|
|
96
|
+
file_bucket = by_file[violation.fetch(:project_filename)] ||= {} #: Hash[untyped, untyped]
|
|
97
|
+
file_bucket[key_for(violation)] = maximum_actual(violation)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Lazily creates the errors key, so callers only touch it when there is a
|
|
102
|
+
# violation to record.
|
|
73
103
|
def bucket(errors, name)
|
|
74
104
|
errors[name] ||= {} #: Hash[untyped, untyped]
|
|
75
105
|
end
|
|
@@ -81,6 +111,10 @@ module SimpleCov
|
|
|
81
111
|
def expected_actual(violation)
|
|
82
112
|
{expected: violation.fetch(:expected), actual: violation.fetch(:actual)}
|
|
83
113
|
end
|
|
114
|
+
|
|
115
|
+
def maximum_actual(violation)
|
|
116
|
+
{maximum: violation.fetch(:maximum), actual: violation.fetch(:actual)}
|
|
117
|
+
end
|
|
84
118
|
end
|
|
85
119
|
end
|
|
86
120
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../production/file_sink"
|
|
4
|
+
|
|
5
|
+
module SimpleCov
|
|
6
|
+
module Formatter
|
|
7
|
+
class JSONFormatter
|
|
8
|
+
module ProductionSectionFormatter
|
|
9
|
+
extend self
|
|
10
|
+
|
|
11
|
+
# The section for the configured store, or nil when none is configured or it
|
|
12
|
+
# is unreadable. An unreadable store warns instead of raising: the report is
|
|
13
|
+
# generated at the end of a test run, and a missing night of production data
|
|
14
|
+
# must not fail the suite that measured the tests.
|
|
15
|
+
def call(path = SimpleCov.production_coverage)
|
|
16
|
+
return nil unless path
|
|
17
|
+
|
|
18
|
+
section(Production::FileSink.read(path))
|
|
19
|
+
rescue SystemCallError, Production::Error => e
|
|
20
|
+
warn "[SimpleCov] skipping production coverage (#{e})"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def section(store)
|
|
24
|
+
section = {} #: Hash[Symbol, untyped]
|
|
25
|
+
started_at = store["started_at"]
|
|
26
|
+
updated_at = store["updated_at"]
|
|
27
|
+
section[:started_at] = started_at if started_at
|
|
28
|
+
section[:updated_at] = updated_at if updated_at
|
|
29
|
+
section[:files] = files(store)
|
|
30
|
+
section
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# `coverage` and `last_seen` are the two keys a parsed store always carries,
|
|
34
|
+
# since `FileSink.parse` substitutes an empty Hash for either one a document
|
|
35
|
+
# is missing.
|
|
36
|
+
def files(store)
|
|
37
|
+
last_seen = store.fetch("last_seen")
|
|
38
|
+
store.fetch("coverage").sort.to_h do |file, lines|
|
|
39
|
+
entry = {lines: lines} #: Hash[Symbol, untyped]
|
|
40
|
+
stamp = last_seen[file]
|
|
41
|
+
entry[:last_seen] = stamp if stamp
|
|
42
|
+
[file, entry]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -3,28 +3,24 @@
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require "time"
|
|
5
5
|
require_relative "errors_formatter"
|
|
6
|
+
require_relative "production_section_formatter"
|
|
6
7
|
require_relative "source_file_formatter"
|
|
7
8
|
|
|
8
9
|
module SimpleCov
|
|
9
10
|
module Formatter
|
|
10
11
|
class JSONFormatter
|
|
11
|
-
# Builds the hash that JSONFormatter serializes to coverage.json:
|
|
12
|
-
# meta, per-file coverage data, group totals, and aggregate stats.
|
|
13
12
|
class ResultHashFormatter
|
|
14
|
-
# Bump
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
# convenience alias that always tracks the latest. See the
|
|
20
|
-
# `coverage.json` schema section of the README for the rationale.
|
|
21
|
-
SCHEMA_VERSION = "1.0"
|
|
13
|
+
# Bump this (and SCHEMA_URL) when the JSON shape changes: additive changes
|
|
14
|
+
# bump the minor segment, removals or shape changes the major. The versioned
|
|
15
|
+
# file at schemas/coverage-vX.Y.schema.json is the canonical artifact
|
|
16
|
+
# consumers should pin to.
|
|
17
|
+
SCHEMA_VERSION = "1.3"
|
|
22
18
|
SCHEMA_URL = "https://raw.githubusercontent.com/simplecov-ruby/simplecov/main/schemas/coverage-v#{SCHEMA_VERSION}.schema.json".freeze
|
|
23
19
|
private_constant :SCHEMA_VERSION, :SCHEMA_URL
|
|
24
20
|
|
|
25
21
|
class << self
|
|
26
22
|
def format(result, include_source: true)
|
|
27
|
-
{
|
|
23
|
+
document = {
|
|
28
24
|
:$schema => SCHEMA_URL,
|
|
29
25
|
:meta => format_meta(result),
|
|
30
26
|
:total => format_coverage_statistics(result.coverage_statistics),
|
|
@@ -32,13 +28,28 @@ module SimpleCov
|
|
|
32
28
|
:groups => format_groups(result),
|
|
33
29
|
:errors => ErrorsFormatter.call(result)
|
|
34
30
|
}
|
|
31
|
+
add_optional_sections(document, result)
|
|
32
|
+
document
|
|
35
33
|
end
|
|
36
34
|
|
|
37
|
-
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Contexts are present exactly when the result carried a complete map, so
|
|
38
|
+
# consumers can tell "recorded and empty" from "not recorded". History is
|
|
39
|
+
# present only when past runs are recorded, since one point is not a trend,
|
|
40
|
+
# and production coverage only when a store is configured and readable.
|
|
41
|
+
def add_optional_sections(document, result)
|
|
42
|
+
document[:contexts] = result.contexts.contexts if result.contexts
|
|
43
|
+
history = History.entries_with(result)
|
|
44
|
+
document[:history] = history if history.length > 1
|
|
45
|
+
production = ProductionSectionFormatter.call
|
|
46
|
+
document[:production] = production if production
|
|
47
|
+
end
|
|
38
48
|
|
|
39
49
|
def format_files(result, include_source:)
|
|
40
50
|
result.files.to_h do |source_file|
|
|
41
|
-
[source_file.project_filename,
|
|
51
|
+
[source_file.project_filename,
|
|
52
|
+
SourceFileFormatter.call(source_file, include_source: include_source, contexts: result.contexts)]
|
|
42
53
|
end
|
|
43
54
|
end
|
|
44
55
|
|
|
@@ -52,26 +63,24 @@ module SimpleCov
|
|
|
52
63
|
def format_meta(result)
|
|
53
64
|
{
|
|
54
65
|
schema_version: SCHEMA_VERSION,
|
|
55
|
-
simplecov_version:
|
|
56
|
-
command_name: result.command_name,
|
|
66
|
+
simplecov_version: VERSION,
|
|
67
|
+
command_name: result.command_name, command_names: result.command_names,
|
|
57
68
|
project_name: SimpleCov.project_name,
|
|
58
69
|
timestamp: result.created_at.iso8601(3),
|
|
59
70
|
root: SimpleCov.root,
|
|
60
71
|
commit: git_commit,
|
|
61
|
-
primary_coverage: SimpleCov.primary_coverage.to_s
|
|
62
|
-
}
|
|
72
|
+
primary_coverage: SimpleCov.primary_coverage.to_s, **coverage_flags
|
|
73
|
+
}
|
|
63
74
|
end
|
|
64
75
|
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
# coverage.json. stderr is captured (not forwarded) so a non-git project
|
|
70
|
-
# doesn't print git's diagnostics to the build.
|
|
76
|
+
# Recorded so tools can recover the exact source a report was generated
|
|
77
|
+
# against, which matters most when `source_in_json false` drops the source
|
|
78
|
+
# text. stderr is captured rather than forwarded so a non-git project doesn't
|
|
79
|
+
# print git's diagnostics to the build.
|
|
71
80
|
def git_commit
|
|
72
|
-
output, status = Open3.capture2e("git", "-C", SimpleCov.root
|
|
73
|
-
|
|
74
|
-
rescue
|
|
81
|
+
output, status = Open3.capture2e("git", "-C", SimpleCov.root, "rev-parse", "HEAD")
|
|
82
|
+
output.strip if status.success?
|
|
83
|
+
rescue
|
|
75
84
|
nil
|
|
76
85
|
end
|
|
77
86
|
|
|
@@ -83,11 +92,16 @@ module SimpleCov
|
|
|
83
92
|
}
|
|
84
93
|
end
|
|
85
94
|
|
|
95
|
+
# A criterion the run did not measure is absent from `statistics` entirely,
|
|
96
|
+
# and gets no section.
|
|
86
97
|
def format_coverage_statistics(statistics)
|
|
87
98
|
result = {} #: Hash[Symbol, untyped]
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
99
|
+
line = statistics[:line]
|
|
100
|
+
branch = statistics[:branch]
|
|
101
|
+
method_stat = statistics[:method]
|
|
102
|
+
result[:lines] = format_line_statistic(line) if line
|
|
103
|
+
result[:branches] = format_single_statistic(branch) if branch
|
|
104
|
+
result[:methods] = format_single_statistic(method_stat) if method_stat
|
|
91
105
|
result
|
|
92
106
|
end
|
|
93
107
|
|
|
@@ -3,26 +3,30 @@
|
|
|
3
3
|
module SimpleCov
|
|
4
4
|
module Formatter
|
|
5
5
|
class JSONFormatter
|
|
6
|
-
# Renders a single `SimpleCov::SourceFile` as the per-file payload
|
|
7
|
-
# in coverage.json: source code plus per-enabled-criterion arrays
|
|
8
|
-
# and totals.
|
|
9
6
|
class SourceFileFormatter
|
|
10
7
|
class << self
|
|
11
|
-
def call(source_file, include_source: true)
|
|
8
|
+
def call(source_file, include_source: true, contexts: nil)
|
|
12
9
|
result = include_source ? format_source_code(source_file) : {} #: Hash[Symbol, untyped]
|
|
13
10
|
result.merge!(line_coverage_section(source_file)) if SimpleCov.line_coverage?
|
|
14
11
|
result.merge!(branch_coverage_section(source_file)) if SimpleCov.branch_coverage?
|
|
15
12
|
result.merge!(method_coverage_section(source_file)) if SimpleCov.method_coverage?
|
|
13
|
+
result.merge!(contexts_section(source_file, contexts)) if contexts
|
|
16
14
|
result
|
|
17
15
|
end
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
private
|
|
20
18
|
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
# An untouched file gets no key at all: under a present document-level
|
|
20
|
+
# `contexts` array, absence already says "no recorded context executed this
|
|
21
|
+
# file".
|
|
22
|
+
def contexts_section(source_file, contexts)
|
|
23
|
+
bitmaps = contexts.serialized_bitmaps_for(source_file.filename)
|
|
24
|
+
bitmaps.empty? ? {} : {contexts: bitmaps}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# No per-line encoding conversion: SourceLoader guarantees every line leaves
|
|
28
|
+
# it as valid UTF-8, and the converter copy this used to make per line was
|
|
29
|
+
# the single largest allocation source in formatting a large report.
|
|
26
30
|
def format_source_code(source_file)
|
|
27
31
|
{source: source_file.lines.map { |line| line.src.chomp }}
|
|
28
32
|
end
|
|
@@ -6,17 +6,12 @@ require "json"
|
|
|
6
6
|
|
|
7
7
|
module SimpleCov
|
|
8
8
|
module Formatter
|
|
9
|
-
# Writes coverage results as JSON to coverage/coverage.json. Used
|
|
10
|
-
# standalone, alongside the HTML formatter, or by external tools that
|
|
11
|
-
# consume SimpleCov output.
|
|
12
9
|
class JSONFormatter < Base
|
|
13
10
|
FILENAME = CoverageJSONWriter::FILENAME
|
|
14
11
|
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# setting (the HTML formatter, which feeds the client-side
|
|
19
|
-
# viewer) pass `include_source: true` explicitly.
|
|
12
|
+
# Callers that need the source array regardless of the global setting (the
|
|
13
|
+
# HTML formatter, which feeds the client-side viewer) pass
|
|
14
|
+
# `include_source: true` explicitly.
|
|
20
15
|
def self.build_hash(result, include_source: SimpleCov.source_in_json)
|
|
21
16
|
ResultHashFormatter.format(result, include_source: include_source)
|
|
22
17
|
end
|
|
@@ -26,7 +21,7 @@ module SimpleCov
|
|
|
26
21
|
emit_status(result)
|
|
27
22
|
end
|
|
28
23
|
|
|
29
|
-
|
|
24
|
+
private
|
|
30
25
|
|
|
31
26
|
def message_prefix
|
|
32
27
|
"JSON "
|
|
@@ -39,8 +34,7 @@ module SimpleCov
|
|
|
39
34
|
end
|
|
40
35
|
end
|
|
41
36
|
|
|
42
|
-
# Loaded after the JSONFormatter class is defined so the nested
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
# chance to declare `JSONFormatter < Base`.
|
|
37
|
+
# Loaded after the JSONFormatter class is defined so the nested reopen inside
|
|
38
|
+
# result_hash_formatter.rb doesn't create a JSONFormatter < Object before this
|
|
39
|
+
# file gets a chance to declare `JSONFormatter < Base`.
|
|
46
40
|
require_relative "json_formatter/result_hash_formatter"
|