simplecov-mcp 0.3.0 → 1.0.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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +173 -356
  4. data/docs/ADVANCED_USAGE.md +967 -0
  5. data/docs/ARCHITECTURE.md +79 -0
  6. data/docs/BRANCH_ONLY_COVERAGE.md +81 -0
  7. data/docs/CLI_USAGE.md +637 -0
  8. data/docs/DEVELOPMENT.md +82 -0
  9. data/docs/ERROR_HANDLING.md +93 -0
  10. data/docs/EXAMPLES.md +430 -0
  11. data/docs/INSTALLATION.md +352 -0
  12. data/docs/LIBRARY_API.md +635 -0
  13. data/docs/MCP_INTEGRATION.md +488 -0
  14. data/docs/TROUBLESHOOTING.md +276 -0
  15. data/docs/arch-decisions/001-x-arch-decision.md +93 -0
  16. data/docs/arch-decisions/002-x-arch-decision.md +157 -0
  17. data/docs/arch-decisions/003-x-arch-decision.md +163 -0
  18. data/docs/arch-decisions/004-x-arch-decision.md +199 -0
  19. data/docs/arch-decisions/005-x-arch-decision.md +187 -0
  20. data/docs/arch-decisions/README.md +60 -0
  21. data/docs/presentations/simplecov-mcp-presentation.md +249 -0
  22. data/exe/simplecov-mcp +4 -4
  23. data/lib/simplecov_mcp/app_context.rb +26 -0
  24. data/lib/simplecov_mcp/base_tool.rb +74 -0
  25. data/lib/simplecov_mcp/cli.rb +234 -0
  26. data/lib/simplecov_mcp/cli_config.rb +56 -0
  27. data/lib/simplecov_mcp/commands/base_command.rb +78 -0
  28. data/lib/simplecov_mcp/commands/command_factory.rb +39 -0
  29. data/lib/simplecov_mcp/commands/detailed_command.rb +24 -0
  30. data/lib/simplecov_mcp/commands/list_command.rb +13 -0
  31. data/lib/simplecov_mcp/commands/raw_command.rb +22 -0
  32. data/lib/simplecov_mcp/commands/summary_command.rb +24 -0
  33. data/lib/simplecov_mcp/commands/uncovered_command.rb +26 -0
  34. data/lib/simplecov_mcp/commands/version_command.rb +18 -0
  35. data/lib/simplecov_mcp/constants.rb +22 -0
  36. data/lib/simplecov_mcp/error_handler.rb +124 -0
  37. data/lib/simplecov_mcp/error_handler_factory.rb +31 -0
  38. data/lib/simplecov_mcp/errors.rb +179 -0
  39. data/lib/simplecov_mcp/formatters/source_formatter.rb +148 -0
  40. data/lib/simplecov_mcp/mcp_server.rb +40 -0
  41. data/lib/simplecov_mcp/mode_detector.rb +55 -0
  42. data/lib/simplecov_mcp/model.rb +300 -0
  43. data/lib/simplecov_mcp/option_normalizers.rb +92 -0
  44. data/lib/simplecov_mcp/option_parser_builder.rb +134 -0
  45. data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +50 -0
  46. data/lib/simplecov_mcp/option_parsers/error_helper.rb +109 -0
  47. data/lib/simplecov_mcp/path_relativizer.rb +61 -0
  48. data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +44 -0
  49. data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +16 -0
  50. data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +16 -0
  51. data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +16 -0
  52. data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +16 -0
  53. data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +52 -0
  54. data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +126 -0
  55. data/lib/simplecov_mcp/resolvers/resolver_factory.rb +28 -0
  56. data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +78 -0
  57. data/lib/simplecov_mcp/resultset_loader.rb +136 -0
  58. data/lib/simplecov_mcp/staleness_checker.rb +243 -0
  59. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/all_files_coverage_tool.rb +31 -13
  60. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_detailed_tool.rb +7 -7
  61. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_raw_tool.rb +7 -7
  62. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_summary_tool.rb +7 -7
  63. data/lib/simplecov_mcp/tools/coverage_table_tool.rb +90 -0
  64. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/help_tool.rb +13 -4
  65. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/uncovered_lines_tool.rb +7 -7
  66. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/version_tool.rb +11 -3
  67. data/lib/simplecov_mcp/util.rb +82 -0
  68. data/lib/{simple_cov_mcp → simplecov_mcp}/version.rb +1 -1
  69. data/lib/simplecov_mcp.rb +144 -2
  70. data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
  71. data/spec/TIMESTAMPS.md +48 -0
  72. data/spec/all_files_coverage_tool_spec.rb +29 -25
  73. data/spec/base_tool_spec.rb +11 -10
  74. data/spec/cli/show_default_report_spec.rb +33 -0
  75. data/spec/cli_config_spec.rb +137 -0
  76. data/spec/cli_enumerated_options_spec.rb +68 -0
  77. data/spec/cli_error_spec.rb +105 -47
  78. data/spec/cli_source_spec.rb +82 -23
  79. data/spec/cli_spec.rb +140 -5
  80. data/spec/cli_success_predicate_spec.rb +141 -0
  81. data/spec/cli_table_spec.rb +1 -1
  82. data/spec/cli_usage_spec.rb +10 -26
  83. data/spec/commands/base_command_spec.rb +187 -0
  84. data/spec/commands/command_factory_spec.rb +72 -0
  85. data/spec/commands/detailed_command_spec.rb +48 -0
  86. data/spec/commands/raw_command_spec.rb +46 -0
  87. data/spec/commands/summary_command_spec.rb +47 -0
  88. data/spec/commands/uncovered_command_spec.rb +49 -0
  89. data/spec/constants_spec.rb +61 -0
  90. data/spec/coverage_table_tool_spec.rb +17 -33
  91. data/spec/error_handler_spec.rb +22 -13
  92. data/spec/error_mode_spec.rb +143 -0
  93. data/spec/errors_edge_cases_spec.rb +239 -0
  94. data/spec/errors_stale_spec.rb +2 -2
  95. data/spec/file_based_mcp_tools_spec.rb +99 -0
  96. data/spec/fixtures/project1/lib/bar.rb +0 -1
  97. data/spec/fixtures/project1/lib/foo.rb +0 -1
  98. data/spec/help_tool_spec.rb +11 -17
  99. data/spec/integration_spec.rb +845 -0
  100. data/spec/logging_fallback_spec.rb +128 -0
  101. data/spec/mcp_logging_spec.rb +44 -0
  102. data/spec/mcp_server_integration_spec.rb +23 -0
  103. data/spec/mcp_server_spec.rb +15 -4
  104. data/spec/mode_detector_spec.rb +148 -0
  105. data/spec/model_error_handling_spec.rb +210 -0
  106. data/spec/model_staleness_spec.rb +40 -10
  107. data/spec/option_normalizers_spec.rb +204 -0
  108. data/spec/option_parsers/env_options_parser_spec.rb +233 -0
  109. data/spec/option_parsers/error_helper_spec.rb +222 -0
  110. data/spec/path_relativizer_spec.rb +83 -0
  111. data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
  112. data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
  113. data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
  114. data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
  115. data/spec/presenters/project_coverage_presenter_spec.rb +86 -0
  116. data/spec/resolvers/coverage_line_resolver_spec.rb +57 -0
  117. data/spec/resolvers/resolver_factory_spec.rb +61 -0
  118. data/spec/resolvers/resultset_path_resolver_spec.rb +55 -0
  119. data/spec/resultset_loader_spec.rb +167 -0
  120. data/spec/shared_examples/README.md +115 -0
  121. data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
  122. data/spec/shared_examples/file_based_mcp_tools.rb +174 -0
  123. data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
  124. data/spec/simple_cov_mcp_module_spec.rb +16 -0
  125. data/spec/simplecov_mcp_model_spec.rb +340 -9
  126. data/spec/simplecov_mcp_opts_spec.rb +182 -0
  127. data/spec/spec_helper.rb +147 -4
  128. data/spec/staleness_checker_spec.rb +373 -0
  129. data/spec/staleness_more_spec.rb +16 -13
  130. data/spec/support/mcp_runner.rb +64 -0
  131. data/spec/tools_error_handling_spec.rb +144 -0
  132. data/spec/util_spec.rb +109 -34
  133. data/spec/version_spec.rb +117 -9
  134. data/spec/version_tool_spec.rb +131 -10
  135. metadata +120 -63
  136. data/lib/simple_cov/mcp.rb +0 -9
  137. data/lib/simple_cov_mcp/base_tool.rb +0 -70
  138. data/lib/simple_cov_mcp/cli.rb +0 -390
  139. data/lib/simple_cov_mcp/error_handler.rb +0 -131
  140. data/lib/simple_cov_mcp/error_handler_factory.rb +0 -38
  141. data/lib/simple_cov_mcp/errors.rb +0 -176
  142. data/lib/simple_cov_mcp/mcp_server.rb +0 -30
  143. data/lib/simple_cov_mcp/model.rb +0 -104
  144. data/lib/simple_cov_mcp/staleness_checker.rb +0 -125
  145. data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +0 -61
  146. data/lib/simple_cov_mcp/util.rb +0 -122
  147. data/lib/simple_cov_mcp.rb +0 -102
  148. data/spec/coverage_detailed_tool_spec.rb +0 -36
  149. data/spec/coverage_raw_tool_spec.rb +0 -32
  150. data/spec/coverage_summary_tool_spec.rb +0 -39
  151. data/spec/legacy_shim_spec.rb +0 -13
  152. data/spec/uncovered_lines_tool_spec.rb +0 -33
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module SimpleCovMcp
6
+ # Utility object that converts configured path-bearing keys to forms
7
+ # relative to the project root while leaving the original payload untouched.
8
+ class PathRelativizer
9
+ def initialize(root:, scalar_keys:, array_keys: [])
10
+ @root = Pathname.new(File.absolute_path(root || '.'))
11
+ @scalar_keys = Array(scalar_keys).map(&:to_s).freeze
12
+ @array_keys = Array(array_keys).map(&:to_s).freeze
13
+ end
14
+
15
+ def relativize(obj)
16
+ deep_copy_and_relativize(obj)
17
+ end
18
+
19
+ private
20
+
21
+ def deep_copy_and_relativize(obj, key_context = nil)
22
+ case obj
23
+ when Hash
24
+ obj.each_with_object({}) do |(k, v), acc|
25
+ acc[k] = relativize_value(k, v)
26
+ end
27
+ when Array
28
+ obj.map { |item| deep_copy_and_relativize(item) }
29
+ else
30
+ obj
31
+ end
32
+ end
33
+
34
+ def relativize_value(key, value)
35
+ key_str = key.to_s
36
+ if @scalar_keys.include?(key_str) && value.is_a?(String)
37
+ relativize_path(value)
38
+ elsif @array_keys.include?(key_str) && value.is_a?(Array)
39
+ value.map do |item|
40
+ item.is_a?(String) ? relativize_path(item) : deep_copy_and_relativize(item)
41
+ end
42
+ else
43
+ deep_copy_and_relativize(value)
44
+ end
45
+ end
46
+
47
+ def relativize_path(path)
48
+ abs = File.absolute_path(path, @root.to_s)
49
+ root_str = @root.to_s
50
+ return path unless abs.start_with?(root_prefix(root_str)) || abs == root_str
51
+
52
+ Pathname.new(abs).relative_path_from(@root).to_s
53
+ rescue ArgumentError
54
+ path
55
+ end
56
+
57
+ def root_prefix(root_str)
58
+ root_str.end_with?(File::SEPARATOR) ? root_str : root_str + File::SEPARATOR
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCovMcp
4
+ module Presenters
5
+ # Shared presenter behavior for single-file coverage payloads.
6
+ class BaseCoveragePresenter
7
+ attr_reader :model, :path
8
+
9
+ def initialize(model:, path:)
10
+ @model = model
11
+ @path = path
12
+ end
13
+
14
+ # Returns the absolute-path payload augmented with stale metadata.
15
+ def absolute_payload
16
+ @absolute_payload ||= begin
17
+ payload = build_payload
18
+ payload.merge('stale' => model.staleness_for(path))
19
+ end
20
+ end
21
+
22
+ # Returns the payload with file paths relativized for presentation.
23
+ def relativized_payload
24
+ @relativized_payload ||= model.relativize(absolute_payload)
25
+ end
26
+
27
+ # Returns the cached stale status for the file.
28
+ def stale
29
+ absolute_payload['stale']
30
+ end
31
+
32
+ # Returns the relativized file path used in CLI output.
33
+ def relative_path
34
+ relativized_payload['file']
35
+ end
36
+
37
+ private
38
+
39
+ def build_payload
40
+ raise NotImplementedError, "#{self.class} must implement #build_payload"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_coverage_presenter'
4
+
5
+ module SimpleCovMcp
6
+ module Presenters
7
+ # Provides shared detailed coverage payloads for CLI and MCP callers.
8
+ class CoverageDetailedPresenter < BaseCoveragePresenter
9
+ private
10
+
11
+ def build_payload
12
+ model.detailed_for(path)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_coverage_presenter'
4
+
5
+ module SimpleCovMcp
6
+ module Presenters
7
+ # Provides shared raw coverage payloads for CLI and MCP callers.
8
+ class CoverageRawPresenter < BaseCoveragePresenter
9
+ private
10
+
11
+ def build_payload
12
+ model.raw_for(path)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_coverage_presenter'
4
+
5
+ module SimpleCovMcp
6
+ module Presenters
7
+ # Builds a consistent summary payload that both the CLI and MCP surfaces can use.
8
+ class CoverageSummaryPresenter < BaseCoveragePresenter
9
+ private
10
+
11
+ def build_payload
12
+ model.summary_for(path)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_coverage_presenter'
4
+
5
+ module SimpleCovMcp
6
+ module Presenters
7
+ # Provides shared uncovered coverage payloads for CLI and MCP callers.
8
+ class CoverageUncoveredPresenter < BaseCoveragePresenter
9
+ private
10
+
11
+ def build_payload
12
+ model.uncovered_for(path)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCovMcp
4
+ module Presenters
5
+ # Provides repository-wide coverage summaries shared by CLI and MCP surfaces.
6
+ class ProjectCoveragePresenter
7
+ attr_reader :model, :sort_order, :check_stale, :tracked_globs
8
+
9
+ def initialize(model:, sort_order:, check_stale:, tracked_globs:)
10
+ @model = model
11
+ @sort_order = sort_order
12
+ @check_stale = check_stale
13
+ @tracked_globs = tracked_globs
14
+ end
15
+
16
+ # Returns the absolute-path payload including counts.
17
+ def absolute_payload
18
+ @absolute_payload ||= begin
19
+ files = model.all_files(
20
+ sort_order: sort_order,
21
+ check_stale: check_stale,
22
+ tracked_globs: tracked_globs
23
+ )
24
+ { 'files' => files, 'counts' => build_counts(files) }
25
+ end
26
+ end
27
+
28
+ # Returns the payload with file paths relativized for presentation.
29
+ def relativized_payload
30
+ @relativized_payload ||= model.relativize(absolute_payload)
31
+ end
32
+
33
+ # Returns the relativized file rows.
34
+ def relative_files
35
+ relativized_payload['files']
36
+ end
37
+
38
+ # Returns the coverage counts with relative file paths.
39
+ def relative_counts
40
+ relativized_payload['counts']
41
+ end
42
+
43
+ private
44
+
45
+ def build_counts(files)
46
+ total = files.length
47
+ stale = files.count { |f| f['stale'] }
48
+ { 'total' => total, 'ok' => total - stale, 'stale' => stale }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCovMcp
4
+ module Resolvers
5
+ class CoverageLineResolver
6
+ def initialize(cov_data)
7
+ @cov_data = cov_data
8
+ end
9
+
10
+ def lookup_lines(file_abs)
11
+ # First try exact match
12
+ if direct_match = find_direct_match(file_abs)
13
+ return direct_match
14
+ end
15
+
16
+ # Then try without current working directory prefix
17
+ if stripped_match = find_stripped_match(file_abs)
18
+ return stripped_match
19
+ end
20
+
21
+ raise_not_found_error(file_abs)
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :cov_data
27
+
28
+ def find_direct_match(file_abs)
29
+ entry = cov_data[file_abs]
30
+ lines_from_entry(entry)
31
+ end
32
+
33
+ def find_stripped_match(file_abs)
34
+ return unless file_abs.start_with?(cwd_with_slash)
35
+
36
+ relative_path = file_abs[(cwd.length + 1)..-1]
37
+ entry = cov_data[relative_path]
38
+ lines_from_entry(entry)
39
+ end
40
+
41
+ def cwd
42
+ @cwd ||= Dir.pwd
43
+ end
44
+
45
+ def cwd_with_slash
46
+ @cwd_with_slash ||= "#{cwd}/"
47
+ end
48
+
49
+ def raise_not_found_error(file_abs)
50
+ raise FileError.new("No coverage entry found for #{file_abs}")
51
+ end
52
+
53
+ # Entry may store exact line coverage, branch-only coverage, or neither.
54
+ # Prefer the provided `lines` array but fall back to synthesizing one so
55
+ # callers always receive something enumerable.
56
+ #
57
+ # Returning nil tells callers to keep searching; the resolver will raise
58
+ # a FileError if no variant yields coverage data.
59
+ def lines_from_entry(entry)
60
+ return unless entry.is_a?(Hash)
61
+
62
+ lines = entry['lines']
63
+ return lines if lines.is_a?(Array)
64
+
65
+ synthesize_lines_from_branches(entry['branches'])
66
+ end
67
+
68
+ # Some SimpleCov configurations track only branch coverage. When the
69
+ # resultset omits the legacy `lines` array we rebuild a minimal substitute
70
+ # so the rest of the pipeline (summaries, uncovered lines, staleness) can
71
+ # continue to operate.
72
+ #
73
+ # Branch data looks like:
74
+ # "[:if, 0, 12, 4, 20, 29]" => { "[:then, ...]" => hits, ... }
75
+ # We care about the third tuple element (line number). We sum branch-leg
76
+ # hits per line so the synthetic array still behaves like legacy line
77
+ # coverage (any positive value counts as executed).
78
+ def synthesize_lines_from_branches(branch_data)
79
+ # Detailed shape and rationale documented in docs/BRANCH_ONLY_COVERAGE.md
80
+ return unless branch_data.is_a?(Hash) && branch_data.any?
81
+
82
+ line_hits = {}
83
+
84
+ branch_data
85
+ .values
86
+ .select { |targets| targets.is_a?(Hash) } # ignore malformed branch entries
87
+ .flat_map(&:to_a) # flatten each branch target into [meta, hits]
88
+ .filter_map do |meta, hits|
89
+ # Extract the covered line; filter_map discards nil results.
90
+ line_number = extract_line_number(meta)
91
+ line_number && [line_number, hits.to_i]
92
+ end
93
+ .each do |line_number, hits|
94
+ line_hits[line_number] = line_hits.fetch(line_number, 0) + hits
95
+ end
96
+
97
+ return if line_hits.empty?
98
+
99
+ max_line = line_hits.keys.max
100
+ # Build a dense array up to the highest line recorded so downstream
101
+ # consumers see the familiar SimpleCov shape (nil for untouched lines).
102
+ Array.new(max_line) { |idx| line_hits[idx + 1] }
103
+ end
104
+
105
+ # Branch metadata arrives as either the raw SimpleCov array
106
+ # (e.g. [:if, 0, 12, 4, 20, 29]) or the stringified JSON version
107
+ # ("[:if, 0, 12, 4, 20, 29]"). We normalize both forms and pull the line.
108
+ def extract_line_number(meta)
109
+ if meta.is_a?(Array)
110
+ line_token = meta[2]
111
+ # Integer(..., exception: false) returns nil on failure, so malformed
112
+ # tuples quietly drop out of the synthesized array.
113
+ return Integer(line_token, exception: false)
114
+ end
115
+
116
+ tokens = meta.to_s.tr('[]', '').split(',').map(&:strip)
117
+ return if tokens.length < 3
118
+
119
+ Integer(tokens[2], exception: false)
120
+ # Any parsing errors result in nil; callers treat that as "no line".
121
+ rescue ArgumentError, TypeError
122
+ nil
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'resultset_path_resolver'
4
+ require_relative 'coverage_line_resolver'
5
+
6
+ module SimpleCovMcp
7
+ module Resolvers
8
+ class ResolverFactory
9
+ def self.create_resultset_resolver(root: Dir.pwd, resultset: nil, candidates: nil)
10
+ candidates ?
11
+ ResultsetPathResolver.new(root: root, candidates: candidates) :
12
+ ResultsetPathResolver.new(root: root)
13
+ end
14
+
15
+ def self.create_coverage_resolver(cov_data)
16
+ CoverageLineResolver.new(cov_data)
17
+ end
18
+
19
+ def self.find_resultset(root, resultset: nil)
20
+ ResultsetPathResolver.new(root: root).find_resultset(resultset: resultset)
21
+ end
22
+
23
+ def self.lookup_lines(cov, file_abs)
24
+ CoverageLineResolver.new(cov).lookup_lines(file_abs)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module SimpleCovMcp
6
+ module Resolvers
7
+ class ResultsetPathResolver
8
+ DEFAULT_CANDIDATES = [
9
+ '.resultset.json',
10
+ 'coverage/.resultset.json',
11
+ 'tmp/.resultset.json'
12
+ ].freeze
13
+
14
+ def initialize(root: Dir.pwd, candidates: DEFAULT_CANDIDATES)
15
+ @root = root
16
+ @candidates = candidates
17
+ end
18
+
19
+ def find_resultset(resultset: nil)
20
+ if resultset && !resultset.empty?
21
+ path = normalize_resultset_path(resultset)
22
+ if (resolved = resolve_candidate(path, strict: true))
23
+ return resolved
24
+ end
25
+ end
26
+
27
+ resolve_fallback or raise_not_found_error
28
+ end
29
+
30
+ private
31
+
32
+ def resolve_candidate(path, strict:)
33
+ return path if File.file?(path)
34
+ return resolve_directory(path) if File.directory?(path)
35
+
36
+ raise_not_found_error_for_file(path) if strict
37
+ nil
38
+ end
39
+
40
+ def resolve_directory(path)
41
+ candidate = File.join(path, '.resultset.json')
42
+ return candidate if File.file?(candidate)
43
+
44
+ raise "No .resultset.json found in directory: #{path}"
45
+ end
46
+
47
+ def raise_not_found_error_for_file(path)
48
+ raise "Specified resultset not found: #{path}"
49
+ end
50
+
51
+ def resolve_fallback
52
+ @candidates
53
+ .map { |p| File.absolute_path(p, @root) }
54
+ .find { |p| File.file?(p) }
55
+ end
56
+
57
+ def normalize_resultset_path(resultset)
58
+ candidate = Pathname.new(resultset)
59
+ return candidate.cleanpath.to_s if candidate.absolute?
60
+
61
+ expanded = File.expand_path(resultset, Dir.pwd)
62
+ return expanded if within_root?(expanded)
63
+
64
+ File.absolute_path(resultset, @root)
65
+ end
66
+
67
+ def within_root?(path)
68
+ normalized_root = Pathname.new(@root).cleanpath.to_s
69
+ root_with_sep = normalized_root.end_with?(File::SEPARATOR) ? normalized_root : "#{normalized_root}#{File::SEPARATOR}"
70
+ path == normalized_root || path.start_with?(root_with_sep)
71
+ end
72
+
73
+ def raise_not_found_error
74
+ raise "Could not find .resultset.json under #{@root.inspect}; run tests or set --resultset option"
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ require_relative 'errors'
7
+ require_relative 'util'
8
+
9
+ module SimpleCovMcp
10
+ class ResultsetLoader
11
+ Result = Struct.new(:coverage_map, :timestamp, :suite_names, keyword_init: true)
12
+ SuiteEntry = Struct.new(:name, :coverage, :timestamp, keyword_init: true)
13
+
14
+ class << self
15
+ def load(resultset_path:)
16
+ raw = JSON.parse(File.read(resultset_path))
17
+
18
+ suites = extract_suite_entries(raw, resultset_path)
19
+ raise CoverageDataError.new("No test suite with coverage data found in resultset file: #{resultset_path}") if suites.empty?
20
+
21
+ coverage_map = build_coverage_map(suites, resultset_path)
22
+ Result.new(
23
+ coverage_map: coverage_map,
24
+ timestamp: compute_combined_timestamp(suites),
25
+ suite_names: suites.map(&:name)
26
+ )
27
+ end
28
+
29
+ private
30
+
31
+ def extract_suite_entries(raw, resultset_path)
32
+ raw
33
+ .select { |_, data| data.is_a?(Hash) && data.key?('coverage') && !data['coverage'].nil? }
34
+ .map do |name, data|
35
+ SuiteEntry.new(
36
+ name: name.to_s,
37
+ coverage: normalize_suite_coverage(data['coverage'], suite_name: name,
38
+ resultset_path: resultset_path),
39
+ timestamp: normalize_coverage_timestamp(data['timestamp'], data['created_at'])
40
+ )
41
+ end
42
+ end
43
+
44
+ def build_coverage_map(suites, resultset_path)
45
+ return suites.first&.coverage if suites.length == 1
46
+
47
+ merge_suite_coverages(suites, resultset_path)
48
+ end
49
+
50
+ def normalize_suite_coverage(coverage, suite_name:, resultset_path:)
51
+ unless coverage.is_a?(Hash)
52
+ raise CoverageDataError.new("Invalid coverage data structure for suite #{suite_name.inspect} in resultset file: #{resultset_path}")
53
+ end
54
+
55
+ needs_adaptation = coverage.values.any? { |value| value.is_a?(Array) }
56
+ return coverage unless needs_adaptation
57
+
58
+ coverage.each_with_object({}) do |(file, value), acc|
59
+ acc[file] = value.is_a?(Array) ? { 'lines' => value } : value
60
+ end
61
+ end
62
+
63
+ def merge_suite_coverages(suites, resultset_path)
64
+ require_simplecov_for_merge!(resultset_path)
65
+ log_duplicate_suite_names(suites)
66
+
67
+ suites.reduce(nil) do |memo, suite|
68
+ coverage = suite.coverage
69
+ memo ?
70
+ SimpleCov::Combine.combine(SimpleCov::Combine::ResultsCombiner, memo, coverage) :
71
+ coverage
72
+ end
73
+ end
74
+
75
+ def require_simplecov_for_merge!(resultset_path)
76
+ require 'simplecov'
77
+ rescue LoadError
78
+ raise CoverageDataError.new(
79
+ "Multiple coverage suites detected in #{resultset_path}, but the simplecov gem could not be loaded. Install simplecov to enable suite merging."
80
+ )
81
+ end
82
+
83
+ def log_duplicate_suite_names(suites)
84
+ grouped = suites.group_by(&:name)
85
+ duplicates = grouped.select { |_, entries| entries.length > 1 }.keys
86
+ return if duplicates.empty?
87
+
88
+ message = "Merging duplicate coverage suites for #{duplicates.join(', ')}"
89
+ CovUtil.log(message)
90
+ rescue StandardError
91
+ # Logging should never block coverage loading
92
+ end
93
+
94
+ def compute_combined_timestamp(suites)
95
+ suites.map(&:timestamp).compact.max.to_i
96
+ end
97
+
98
+ def normalize_coverage_timestamp(timestamp_value, created_at_value)
99
+ raw = timestamp_value.nil? ? created_at_value : timestamp_value
100
+ return 0 if raw.nil?
101
+
102
+ case raw
103
+ when Integer
104
+ raw
105
+ when Float, Time
106
+ raw.to_i
107
+ when String
108
+ normalize_string_timestamp(raw)
109
+ else
110
+ log_timestamp_warning(raw)
111
+ 0
112
+ end
113
+ rescue StandardError => e
114
+ log_timestamp_warning(raw, e)
115
+ 0
116
+ end
117
+
118
+ def normalize_string_timestamp(value)
119
+ str = value.strip
120
+ return 0 if str.empty?
121
+
122
+ if str.match?(/\A-?\d+(\.\d+)?\z/)
123
+ str.to_f.to_i
124
+ else
125
+ Time.parse(str).to_i
126
+ end
127
+ end
128
+
129
+ def log_timestamp_warning(raw_value, error = nil)
130
+ message = "Coverage resultset timestamp could not be parsed: #{raw_value.inspect}"
131
+ message = "#{message} (#{error.message})" if error
132
+ CovUtil.log(message) rescue nil
133
+ end
134
+ end
135
+ end
136
+ end