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.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +173 -356
- data/docs/ADVANCED_USAGE.md +967 -0
- data/docs/ARCHITECTURE.md +79 -0
- data/docs/BRANCH_ONLY_COVERAGE.md +81 -0
- data/docs/CLI_USAGE.md +637 -0
- data/docs/DEVELOPMENT.md +82 -0
- data/docs/ERROR_HANDLING.md +93 -0
- data/docs/EXAMPLES.md +430 -0
- data/docs/INSTALLATION.md +352 -0
- data/docs/LIBRARY_API.md +635 -0
- data/docs/MCP_INTEGRATION.md +488 -0
- data/docs/TROUBLESHOOTING.md +276 -0
- data/docs/arch-decisions/001-x-arch-decision.md +93 -0
- data/docs/arch-decisions/002-x-arch-decision.md +157 -0
- data/docs/arch-decisions/003-x-arch-decision.md +163 -0
- data/docs/arch-decisions/004-x-arch-decision.md +199 -0
- data/docs/arch-decisions/005-x-arch-decision.md +187 -0
- data/docs/arch-decisions/README.md +60 -0
- data/docs/presentations/simplecov-mcp-presentation.md +249 -0
- data/exe/simplecov-mcp +4 -4
- data/lib/simplecov_mcp/app_context.rb +26 -0
- data/lib/simplecov_mcp/base_tool.rb +74 -0
- data/lib/simplecov_mcp/cli.rb +234 -0
- data/lib/simplecov_mcp/cli_config.rb +56 -0
- data/lib/simplecov_mcp/commands/base_command.rb +78 -0
- data/lib/simplecov_mcp/commands/command_factory.rb +39 -0
- data/lib/simplecov_mcp/commands/detailed_command.rb +24 -0
- data/lib/simplecov_mcp/commands/list_command.rb +13 -0
- data/lib/simplecov_mcp/commands/raw_command.rb +22 -0
- data/lib/simplecov_mcp/commands/summary_command.rb +24 -0
- data/lib/simplecov_mcp/commands/uncovered_command.rb +26 -0
- data/lib/simplecov_mcp/commands/version_command.rb +18 -0
- data/lib/simplecov_mcp/constants.rb +22 -0
- data/lib/simplecov_mcp/error_handler.rb +124 -0
- data/lib/simplecov_mcp/error_handler_factory.rb +31 -0
- data/lib/simplecov_mcp/errors.rb +179 -0
- data/lib/simplecov_mcp/formatters/source_formatter.rb +148 -0
- data/lib/simplecov_mcp/mcp_server.rb +40 -0
- data/lib/simplecov_mcp/mode_detector.rb +55 -0
- data/lib/simplecov_mcp/model.rb +300 -0
- data/lib/simplecov_mcp/option_normalizers.rb +92 -0
- data/lib/simplecov_mcp/option_parser_builder.rb +134 -0
- data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +50 -0
- data/lib/simplecov_mcp/option_parsers/error_helper.rb +109 -0
- data/lib/simplecov_mcp/path_relativizer.rb +61 -0
- data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +44 -0
- data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +52 -0
- data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +126 -0
- data/lib/simplecov_mcp/resolvers/resolver_factory.rb +28 -0
- data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +78 -0
- data/lib/simplecov_mcp/resultset_loader.rb +136 -0
- data/lib/simplecov_mcp/staleness_checker.rb +243 -0
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/all_files_coverage_tool.rb +31 -13
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_detailed_tool.rb +7 -7
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_raw_tool.rb +7 -7
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_summary_tool.rb +7 -7
- data/lib/simplecov_mcp/tools/coverage_table_tool.rb +90 -0
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/help_tool.rb +13 -4
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/uncovered_lines_tool.rb +7 -7
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/version_tool.rb +11 -3
- data/lib/simplecov_mcp/util.rb +82 -0
- data/lib/{simple_cov_mcp → simplecov_mcp}/version.rb +1 -1
- data/lib/simplecov_mcp.rb +144 -2
- data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
- data/spec/TIMESTAMPS.md +48 -0
- data/spec/all_files_coverage_tool_spec.rb +29 -25
- data/spec/base_tool_spec.rb +11 -10
- data/spec/cli/show_default_report_spec.rb +33 -0
- data/spec/cli_config_spec.rb +137 -0
- data/spec/cli_enumerated_options_spec.rb +68 -0
- data/spec/cli_error_spec.rb +105 -47
- data/spec/cli_source_spec.rb +82 -23
- data/spec/cli_spec.rb +140 -5
- data/spec/cli_success_predicate_spec.rb +141 -0
- data/spec/cli_table_spec.rb +1 -1
- data/spec/cli_usage_spec.rb +10 -26
- data/spec/commands/base_command_spec.rb +187 -0
- data/spec/commands/command_factory_spec.rb +72 -0
- data/spec/commands/detailed_command_spec.rb +48 -0
- data/spec/commands/raw_command_spec.rb +46 -0
- data/spec/commands/summary_command_spec.rb +47 -0
- data/spec/commands/uncovered_command_spec.rb +49 -0
- data/spec/constants_spec.rb +61 -0
- data/spec/coverage_table_tool_spec.rb +17 -33
- data/spec/error_handler_spec.rb +22 -13
- data/spec/error_mode_spec.rb +143 -0
- data/spec/errors_edge_cases_spec.rb +239 -0
- data/spec/errors_stale_spec.rb +2 -2
- data/spec/file_based_mcp_tools_spec.rb +99 -0
- data/spec/fixtures/project1/lib/bar.rb +0 -1
- data/spec/fixtures/project1/lib/foo.rb +0 -1
- data/spec/help_tool_spec.rb +11 -17
- data/spec/integration_spec.rb +845 -0
- data/spec/logging_fallback_spec.rb +128 -0
- data/spec/mcp_logging_spec.rb +44 -0
- data/spec/mcp_server_integration_spec.rb +23 -0
- data/spec/mcp_server_spec.rb +15 -4
- data/spec/mode_detector_spec.rb +148 -0
- data/spec/model_error_handling_spec.rb +210 -0
- data/spec/model_staleness_spec.rb +40 -10
- data/spec/option_normalizers_spec.rb +204 -0
- data/spec/option_parsers/env_options_parser_spec.rb +233 -0
- data/spec/option_parsers/error_helper_spec.rb +222 -0
- data/spec/path_relativizer_spec.rb +83 -0
- data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
- data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
- data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
- data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
- data/spec/presenters/project_coverage_presenter_spec.rb +86 -0
- data/spec/resolvers/coverage_line_resolver_spec.rb +57 -0
- data/spec/resolvers/resolver_factory_spec.rb +61 -0
- data/spec/resolvers/resultset_path_resolver_spec.rb +55 -0
- data/spec/resultset_loader_spec.rb +167 -0
- data/spec/shared_examples/README.md +115 -0
- data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
- data/spec/shared_examples/file_based_mcp_tools.rb +174 -0
- data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
- data/spec/simple_cov_mcp_module_spec.rb +16 -0
- data/spec/simplecov_mcp_model_spec.rb +340 -9
- data/spec/simplecov_mcp_opts_spec.rb +182 -0
- data/spec/spec_helper.rb +147 -4
- data/spec/staleness_checker_spec.rb +373 -0
- data/spec/staleness_more_spec.rb +16 -13
- data/spec/support/mcp_runner.rb +64 -0
- data/spec/tools_error_handling_spec.rb +144 -0
- data/spec/util_spec.rb +109 -34
- data/spec/version_spec.rb +117 -9
- data/spec/version_tool_spec.rb +131 -10
- metadata +120 -63
- data/lib/simple_cov/mcp.rb +0 -9
- data/lib/simple_cov_mcp/base_tool.rb +0 -70
- data/lib/simple_cov_mcp/cli.rb +0 -390
- data/lib/simple_cov_mcp/error_handler.rb +0 -131
- data/lib/simple_cov_mcp/error_handler_factory.rb +0 -38
- data/lib/simple_cov_mcp/errors.rb +0 -176
- data/lib/simple_cov_mcp/mcp_server.rb +0 -30
- data/lib/simple_cov_mcp/model.rb +0 -104
- data/lib/simple_cov_mcp/staleness_checker.rb +0 -125
- data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +0 -61
- data/lib/simple_cov_mcp/util.rb +0 -122
- data/lib/simple_cov_mcp.rb +0 -102
- data/spec/coverage_detailed_tool_spec.rb +0 -36
- data/spec/coverage_raw_tool_spec.rb +0 -32
- data/spec/coverage_summary_tool_spec.rb +0 -39
- data/spec/legacy_shim_spec.rb +0 -13
- 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
|