simplecov-mcp 1.0.1 → 2.0.1
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 +98 -50
- data/docs/{ARCHITECTURE.md → dev/ARCHITECTURE.md} +11 -10
- data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
- data/docs/{DEVELOPMENT.md → dev/DEVELOPMENT.md} +2 -1
- data/docs/dev/README.md +10 -0
- data/docs/dev/RELEASING.md +146 -0
- data/docs/{arch-decisions → dev/arch-decisions}/001-x-arch-decision.md +3 -1
- data/docs/{arch-decisions → dev/arch-decisions}/002-x-arch-decision.md +7 -5
- data/docs/{arch-decisions → dev/arch-decisions}/003-x-arch-decision.md +2 -0
- data/docs/{arch-decisions → dev/arch-decisions}/004-x-arch-decision.md +6 -2
- data/docs/{arch-decisions → dev/arch-decisions}/005-x-arch-decision.md +4 -2
- data/docs/{arch-decisions → dev/arch-decisions}/README.md +3 -3
- data/docs/{presentations → dev/presentations}/simplecov-mcp-presentation.md +28 -22
- data/docs/fixtures/demo_project/README.md +9 -0
- data/docs/{ADVANCED_USAGE.md → user/ADVANCED_USAGE.md} +129 -319
- data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
- data/docs/user/CLI_USAGE.md +750 -0
- data/docs/{ERROR_HANDLING.md → user/ERROR_HANDLING.md} +12 -12
- data/docs/user/EXAMPLES.md +588 -0
- data/docs/user/INSTALLATION.md +130 -0
- data/docs/{LIBRARY_API.md → user/LIBRARY_API.md} +90 -32
- data/docs/{MCP_INTEGRATION.md → user/MCP_INTEGRATION.md} +36 -34
- data/docs/user/README.md +14 -0
- data/docs/{TROUBLESHOOTING.md → user/TROUBLESHOOTING.md} +21 -100
- data/docs/user/V2-BREAKING-CHANGES.md +472 -0
- data/exe/simplecov-mcp +1 -1
- data/lib/simplecov_mcp/{cli_config.rb → app_config.rb} +12 -12
- data/lib/simplecov_mcp/app_context.rb +1 -1
- data/lib/simplecov_mcp/base_tool.rb +66 -38
- data/lib/simplecov_mcp/cli.rb +67 -123
- data/lib/simplecov_mcp/commands/base_command.rb +16 -27
- data/lib/simplecov_mcp/commands/command_factory.rb +8 -2
- data/lib/simplecov_mcp/commands/detailed_command.rb +16 -2
- data/lib/simplecov_mcp/commands/list_command.rb +1 -1
- data/lib/simplecov_mcp/commands/raw_command.rb +18 -2
- data/lib/simplecov_mcp/commands/summary_command.rb +20 -3
- data/lib/simplecov_mcp/commands/totals_command.rb +53 -0
- data/lib/simplecov_mcp/commands/uncovered_command.rb +24 -5
- data/lib/simplecov_mcp/commands/validate_command.rb +60 -0
- data/lib/simplecov_mcp/commands/version_command.rb +19 -4
- data/lib/simplecov_mcp/config_parser.rb +32 -0
- data/lib/simplecov_mcp/constants.rb +3 -3
- data/lib/simplecov_mcp/coverage_reporter.rb +31 -0
- data/lib/simplecov_mcp/error_handler.rb +81 -40
- data/lib/simplecov_mcp/error_handler_factory.rb +2 -2
- data/lib/simplecov_mcp/errors.rb +12 -19
- data/lib/simplecov_mcp/formatters/source_formatter.rb +23 -19
- data/lib/simplecov_mcp/formatters.rb +51 -0
- data/lib/simplecov_mcp/mcp_server.rb +9 -7
- data/lib/simplecov_mcp/mode_detector.rb +6 -5
- data/lib/simplecov_mcp/model.rb +122 -88
- data/lib/simplecov_mcp/option_normalizers.rb +39 -18
- data/lib/simplecov_mcp/option_parser_builder.rb +85 -72
- data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +3 -5
- data/lib/simplecov_mcp/option_parsers/error_helper.rb +18 -17
- data/lib/simplecov_mcp/path_relativizer.rb +17 -14
- data/lib/simplecov_mcp/predicate_evaluator.rb +72 -0
- data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/project_totals_presenter.rb +27 -0
- data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +14 -18
- data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +7 -9
- data/lib/simplecov_mcp/resultset_loader.rb +20 -25
- data/lib/simplecov_mcp/staleness_checker.rb +50 -46
- data/lib/simplecov_mcp/table_formatter.rb +64 -0
- data/lib/simplecov_mcp/tools/all_files_coverage_tool.rb +20 -50
- data/lib/simplecov_mcp/tools/coverage_detailed_tool.rb +13 -7
- data/lib/simplecov_mcp/tools/coverage_raw_tool.rb +12 -7
- data/lib/simplecov_mcp/tools/coverage_summary_tool.rb +13 -8
- data/lib/simplecov_mcp/tools/coverage_table_tool.rb +20 -60
- data/lib/simplecov_mcp/tools/coverage_totals_tool.rb +44 -0
- data/lib/simplecov_mcp/tools/help_tool.rb +38 -66
- data/lib/simplecov_mcp/tools/uncovered_lines_tool.rb +13 -8
- data/lib/simplecov_mcp/tools/validate_tool.rb +72 -0
- data/lib/simplecov_mcp/tools/version_tool.rb +7 -14
- data/lib/simplecov_mcp/util.rb +18 -12
- data/lib/simplecov_mcp/version.rb +1 -1
- data/lib/simplecov_mcp.rb +23 -29
- data/spec/all_files_coverage_tool_spec.rb +4 -3
- data/spec/{cli_config_spec.rb → app_config_spec.rb} +31 -26
- data/spec/base_tool_spec.rb +17 -14
- data/spec/cli/show_default_report_spec.rb +2 -2
- data/spec/cli_enumerated_options_spec.rb +31 -9
- data/spec/cli_error_spec.rb +46 -23
- data/spec/cli_format_spec.rb +123 -0
- data/spec/cli_json_options_spec.rb +50 -0
- data/spec/cli_source_spec.rb +11 -63
- data/spec/cli_spec.rb +82 -97
- data/spec/cli_usage_spec.rb +15 -15
- data/spec/commands/base_command_spec.rb +12 -92
- data/spec/commands/command_factory_spec.rb +7 -3
- data/spec/commands/detailed_command_spec.rb +10 -24
- data/spec/commands/list_command_spec.rb +28 -0
- data/spec/commands/raw_command_spec.rb +43 -20
- data/spec/commands/summary_command_spec.rb +10 -23
- data/spec/commands/totals_command_spec.rb +34 -0
- data/spec/commands/uncovered_command_spec.rb +29 -23
- data/spec/commands/validate_command_spec.rb +213 -0
- data/spec/commands/version_command_spec.rb +38 -0
- data/spec/constants_spec.rb +3 -3
- data/spec/coverage_reporter_spec.rb +102 -0
- data/spec/coverage_table_tool_spec.rb +21 -10
- data/spec/coverage_totals_tool_spec.rb +37 -0
- data/spec/error_handler_spec.rb +120 -4
- data/spec/error_mode_spec.rb +18 -22
- data/spec/errors_edge_cases_spec.rb +101 -28
- data/spec/errors_stale_spec.rb +34 -0
- data/spec/file_based_mcp_tools_spec.rb +6 -6
- data/spec/fixtures/project1/lib/bar.rb +2 -0
- data/spec/fixtures/project1/lib/foo.rb +2 -0
- data/spec/help_tool_spec.rb +2 -18
- data/spec/integration_spec.rb +103 -161
- data/spec/logging_fallback_spec.rb +3 -3
- data/spec/mcp_server_integration_spec.rb +1 -1
- data/spec/mcp_server_spec.rb +70 -53
- data/spec/mode_detector_spec.rb +46 -41
- data/spec/model_error_handling_spec.rb +139 -78
- data/spec/model_staleness_spec.rb +13 -13
- data/spec/option_normalizers_spec.rb +111 -112
- data/spec/option_parsers/env_options_parser_spec.rb +25 -37
- data/spec/option_parsers/error_helper_spec.rb +56 -56
- data/spec/path_relativizer_spec.rb +15 -0
- data/spec/presenters/coverage_detailed_presenter_spec.rb +1 -1
- data/spec/presenters/coverage_summary_presenter_spec.rb +1 -1
- data/spec/presenters/coverage_uncovered_presenter_spec.rb +1 -1
- data/spec/presenters/project_coverage_presenter_spec.rb +9 -8
- data/spec/presenters/project_totals_presenter_spec.rb +144 -0
- data/spec/resolvers/coverage_line_resolver_spec.rb +261 -36
- data/spec/resolvers/resultset_path_resolver_spec.rb +13 -8
- data/spec/shared_examples/file_based_mcp_tools.rb +23 -18
- data/spec/shared_examples/formatted_command_examples.rb +64 -0
- data/spec/simple_cov_mcp_module_spec.rb +24 -3
- data/spec/simplecov_mcp/formatters/source_formatter_spec.rb +267 -0
- data/spec/simplecov_mcp/formatters_spec.rb +76 -0
- data/spec/simplecov_mcp/presenters/base_coverage_presenter_spec.rb +79 -0
- data/spec/simplecov_mcp_model_spec.rb +97 -47
- data/spec/simplecov_mcp_opts_spec.rb +42 -39
- data/spec/spec_helper.rb +27 -92
- data/spec/staleness_checker_spec.rb +10 -9
- data/spec/staleness_more_spec.rb +4 -4
- data/spec/support/cli_helpers.rb +22 -0
- data/spec/support/control_flow_helpers.rb +20 -0
- data/spec/support/fake_mcp.rb +40 -0
- data/spec/support/io_helpers.rb +29 -0
- data/spec/support/mcp_helpers.rb +35 -0
- data/spec/support/mcp_runner.rb +10 -8
- data/spec/support/mocking_helpers.rb +30 -0
- data/spec/table_format_spec.rb +70 -0
- data/spec/tools/validate_tool_spec.rb +132 -0
- data/spec/tools_error_handling_spec.rb +34 -48
- data/spec/util_spec.rb +5 -4
- data/spec/version_spec.rb +7 -7
- data/spec/version_tool_spec.rb +20 -22
- metadata +90 -23
- data/docs/BRANCH_ONLY_COVERAGE.md +0 -81
- data/docs/CLI_USAGE.md +0 -637
- data/docs/EXAMPLES.md +0 -430
- data/docs/INSTALLATION.md +0 -352
- data/spec/cli_success_predicate_spec.rb +0 -141
|
@@ -9,45 +9,41 @@ module SimpleCovMcp
|
|
|
9
9
|
|
|
10
10
|
def lookup_lines(file_abs)
|
|
11
11
|
# First try exact match
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
end
|
|
12
|
+
direct_match = find_direct_match(file_abs)
|
|
13
|
+
return direct_match if direct_match
|
|
15
14
|
|
|
16
15
|
# Then try without current working directory prefix
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
end
|
|
16
|
+
stripped_match = find_stripped_match(file_abs)
|
|
17
|
+
return stripped_match if stripped_match
|
|
20
18
|
|
|
21
19
|
raise_not_found_error(file_abs)
|
|
22
20
|
end
|
|
23
21
|
|
|
24
|
-
private
|
|
25
|
-
|
|
26
22
|
attr_reader :cov_data
|
|
27
23
|
|
|
28
|
-
def find_direct_match(file_abs)
|
|
24
|
+
private def find_direct_match(file_abs)
|
|
29
25
|
entry = cov_data[file_abs]
|
|
30
26
|
lines_from_entry(entry)
|
|
31
27
|
end
|
|
32
28
|
|
|
33
|
-
def find_stripped_match(file_abs)
|
|
29
|
+
private def find_stripped_match(file_abs)
|
|
34
30
|
return unless file_abs.start_with?(cwd_with_slash)
|
|
35
31
|
|
|
36
|
-
relative_path = file_abs[(cwd.length + 1)
|
|
32
|
+
relative_path = file_abs[(cwd.length + 1)..]
|
|
37
33
|
entry = cov_data[relative_path]
|
|
38
34
|
lines_from_entry(entry)
|
|
39
35
|
end
|
|
40
36
|
|
|
41
|
-
def cwd
|
|
37
|
+
private def cwd
|
|
42
38
|
@cwd ||= Dir.pwd
|
|
43
39
|
end
|
|
44
40
|
|
|
45
|
-
def cwd_with_slash
|
|
41
|
+
private def cwd_with_slash
|
|
46
42
|
@cwd_with_slash ||= "#{cwd}/"
|
|
47
43
|
end
|
|
48
44
|
|
|
49
|
-
def raise_not_found_error(file_abs)
|
|
50
|
-
raise FileError
|
|
45
|
+
private def raise_not_found_error(file_abs)
|
|
46
|
+
raise FileError, "No coverage entry found for #{file_abs}"
|
|
51
47
|
end
|
|
52
48
|
|
|
53
49
|
# Entry may store exact line coverage, branch-only coverage, or neither.
|
|
@@ -56,7 +52,7 @@ module SimpleCovMcp
|
|
|
56
52
|
#
|
|
57
53
|
# Returning nil tells callers to keep searching; the resolver will raise
|
|
58
54
|
# a FileError if no variant yields coverage data.
|
|
59
|
-
def lines_from_entry(entry)
|
|
55
|
+
private def lines_from_entry(entry)
|
|
60
56
|
return unless entry.is_a?(Hash)
|
|
61
57
|
|
|
62
58
|
lines = entry['lines']
|
|
@@ -75,7 +71,7 @@ module SimpleCovMcp
|
|
|
75
71
|
# We care about the third tuple element (line number). We sum branch-leg
|
|
76
72
|
# hits per line so the synthetic array still behaves like legacy line
|
|
77
73
|
# coverage (any positive value counts as executed).
|
|
78
|
-
def synthesize_lines_from_branches(branch_data)
|
|
74
|
+
private def synthesize_lines_from_branches(branch_data)
|
|
79
75
|
# Detailed shape and rationale documented in docs/BRANCH_ONLY_COVERAGE.md
|
|
80
76
|
return unless branch_data.is_a?(Hash) && branch_data.any?
|
|
81
77
|
|
|
@@ -105,7 +101,7 @@ module SimpleCovMcp
|
|
|
105
101
|
# Branch metadata arrives as either the raw SimpleCov array
|
|
106
102
|
# (e.g. [:if, 0, 12, 4, 20, 29]) or the stringified JSON version
|
|
107
103
|
# ("[:if, 0, 12, 4, 20, 29]"). We normalize both forms and pull the line.
|
|
108
|
-
def extract_line_number(meta)
|
|
104
|
+
private def extract_line_number(meta)
|
|
109
105
|
if meta.is_a?(Array)
|
|
110
106
|
line_token = meta[2]
|
|
111
107
|
# Integer(..., exception: false) returns nil on failure, so malformed
|
|
@@ -27,9 +27,7 @@ module SimpleCovMcp
|
|
|
27
27
|
resolve_fallback or raise_not_found_error
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
private
|
|
31
|
-
|
|
32
|
-
def resolve_candidate(path, strict:)
|
|
30
|
+
private def resolve_candidate(path, strict:)
|
|
33
31
|
return path if File.file?(path)
|
|
34
32
|
return resolve_directory(path) if File.directory?(path)
|
|
35
33
|
|
|
@@ -37,24 +35,24 @@ module SimpleCovMcp
|
|
|
37
35
|
nil
|
|
38
36
|
end
|
|
39
37
|
|
|
40
|
-
def resolve_directory(path)
|
|
38
|
+
private def resolve_directory(path)
|
|
41
39
|
candidate = File.join(path, '.resultset.json')
|
|
42
40
|
return candidate if File.file?(candidate)
|
|
43
41
|
|
|
44
42
|
raise "No .resultset.json found in directory: #{path}"
|
|
45
43
|
end
|
|
46
44
|
|
|
47
|
-
def raise_not_found_error_for_file(path)
|
|
45
|
+
private def raise_not_found_error_for_file(path)
|
|
48
46
|
raise "Specified resultset not found: #{path}"
|
|
49
47
|
end
|
|
50
48
|
|
|
51
|
-
def resolve_fallback
|
|
49
|
+
private def resolve_fallback
|
|
52
50
|
@candidates
|
|
53
51
|
.map { |p| File.absolute_path(p, @root) }
|
|
54
52
|
.find { |p| File.file?(p) }
|
|
55
53
|
end
|
|
56
54
|
|
|
57
|
-
def normalize_resultset_path(resultset)
|
|
55
|
+
private def normalize_resultset_path(resultset)
|
|
58
56
|
candidate = Pathname.new(resultset)
|
|
59
57
|
return candidate.cleanpath.to_s if candidate.absolute?
|
|
60
58
|
|
|
@@ -64,13 +62,13 @@ module SimpleCovMcp
|
|
|
64
62
|
File.absolute_path(resultset, @root)
|
|
65
63
|
end
|
|
66
64
|
|
|
67
|
-
def within_root?(path)
|
|
65
|
+
private def within_root?(path)
|
|
68
66
|
normalized_root = Pathname.new(@root).cleanpath.to_s
|
|
69
67
|
root_with_sep = normalized_root.end_with?(File::SEPARATOR) ? normalized_root : "#{normalized_root}#{File::SEPARATOR}"
|
|
70
68
|
path == normalized_root || path.start_with?(root_with_sep)
|
|
71
69
|
end
|
|
72
70
|
|
|
73
|
-
def raise_not_found_error
|
|
71
|
+
private def raise_not_found_error
|
|
74
72
|
raise "Could not find .resultset.json under #{@root.inspect}; run tests or set --resultset option"
|
|
75
73
|
end
|
|
76
74
|
end
|
|
@@ -13,10 +13,11 @@ module SimpleCovMcp
|
|
|
13
13
|
|
|
14
14
|
class << self
|
|
15
15
|
def load(resultset_path:)
|
|
16
|
-
raw = JSON.
|
|
16
|
+
raw = JSON.load_file(resultset_path)
|
|
17
|
+
|
|
17
18
|
|
|
18
19
|
suites = extract_suite_entries(raw, resultset_path)
|
|
19
|
-
raise CoverageDataError
|
|
20
|
+
raise CoverageDataError, "No test suite with coverage data found in resultset file: #{resultset_path}" if suites.empty?
|
|
20
21
|
|
|
21
22
|
coverage_map = build_coverage_map(suites, resultset_path)
|
|
22
23
|
Result.new(
|
|
@@ -26,9 +27,7 @@ module SimpleCovMcp
|
|
|
26
27
|
)
|
|
27
28
|
end
|
|
28
29
|
|
|
29
|
-
private
|
|
30
|
-
|
|
31
|
-
def extract_suite_entries(raw, resultset_path)
|
|
30
|
+
private def extract_suite_entries(raw, resultset_path)
|
|
32
31
|
raw
|
|
33
32
|
.select { |_, data| data.is_a?(Hash) && data.key?('coverage') && !data['coverage'].nil? }
|
|
34
33
|
.map do |name, data|
|
|
@@ -41,26 +40,26 @@ module SimpleCovMcp
|
|
|
41
40
|
end
|
|
42
41
|
end
|
|
43
42
|
|
|
44
|
-
def build_coverage_map(suites, resultset_path)
|
|
43
|
+
private def build_coverage_map(suites, resultset_path)
|
|
45
44
|
return suites.first&.coverage if suites.length == 1
|
|
46
45
|
|
|
47
46
|
merge_suite_coverages(suites, resultset_path)
|
|
48
47
|
end
|
|
49
48
|
|
|
50
|
-
def normalize_suite_coverage(coverage, suite_name:, resultset_path:)
|
|
49
|
+
private def normalize_suite_coverage(coverage, suite_name:, resultset_path:)
|
|
51
50
|
unless coverage.is_a?(Hash)
|
|
52
|
-
raise CoverageDataError
|
|
51
|
+
raise CoverageDataError, "Invalid coverage data structure for suite #{suite_name.inspect} in resultset file: #{resultset_path}"
|
|
53
52
|
end
|
|
54
53
|
|
|
55
54
|
needs_adaptation = coverage.values.any? { |value| value.is_a?(Array) }
|
|
56
55
|
return coverage unless needs_adaptation
|
|
57
56
|
|
|
58
|
-
coverage.
|
|
59
|
-
|
|
57
|
+
coverage.transform_values do |value|
|
|
58
|
+
value.is_a?(Array) ? { 'lines' => value } : value
|
|
60
59
|
end
|
|
61
60
|
end
|
|
62
61
|
|
|
63
|
-
def merge_suite_coverages(suites, resultset_path)
|
|
62
|
+
private def merge_suite_coverages(suites, resultset_path)
|
|
64
63
|
require_simplecov_for_merge!(resultset_path)
|
|
65
64
|
log_duplicate_suite_names(suites)
|
|
66
65
|
|
|
@@ -72,30 +71,26 @@ module SimpleCovMcp
|
|
|
72
71
|
end
|
|
73
72
|
end
|
|
74
73
|
|
|
75
|
-
def require_simplecov_for_merge!(resultset_path)
|
|
74
|
+
private def require_simplecov_for_merge!(resultset_path)
|
|
76
75
|
require 'simplecov'
|
|
77
76
|
rescue LoadError
|
|
78
|
-
raise CoverageDataError.
|
|
79
|
-
"Multiple coverage suites detected in #{resultset_path}, but the simplecov gem could not be loaded. Install simplecov to enable suite merging."
|
|
80
|
-
)
|
|
77
|
+
raise CoverageDataError, "Multiple coverage suites detected in #{resultset_path}, but the simplecov gem could not be loaded. Install simplecov to enable suite merging."
|
|
81
78
|
end
|
|
82
79
|
|
|
83
|
-
def log_duplicate_suite_names(suites)
|
|
80
|
+
private def log_duplicate_suite_names(suites)
|
|
84
81
|
grouped = suites.group_by(&:name)
|
|
85
82
|
duplicates = grouped.select { |_, entries| entries.length > 1 }.keys
|
|
86
83
|
return if duplicates.empty?
|
|
87
84
|
|
|
88
85
|
message = "Merging duplicate coverage suites for #{duplicates.join(', ')}"
|
|
89
|
-
CovUtil.
|
|
90
|
-
rescue StandardError
|
|
91
|
-
# Logging should never block coverage loading
|
|
86
|
+
CovUtil.safe_log(message)
|
|
92
87
|
end
|
|
93
88
|
|
|
94
|
-
def compute_combined_timestamp(suites)
|
|
89
|
+
private def compute_combined_timestamp(suites)
|
|
95
90
|
suites.map(&:timestamp).compact.max.to_i
|
|
96
91
|
end
|
|
97
92
|
|
|
98
|
-
def normalize_coverage_timestamp(timestamp_value, created_at_value)
|
|
93
|
+
private def normalize_coverage_timestamp(timestamp_value, created_at_value)
|
|
99
94
|
raw = timestamp_value.nil? ? created_at_value : timestamp_value
|
|
100
95
|
return 0 if raw.nil?
|
|
101
96
|
|
|
@@ -110,12 +105,12 @@ module SimpleCovMcp
|
|
|
110
105
|
log_timestamp_warning(raw)
|
|
111
106
|
0
|
|
112
107
|
end
|
|
113
|
-
rescue
|
|
108
|
+
rescue => e
|
|
114
109
|
log_timestamp_warning(raw, e)
|
|
115
110
|
0
|
|
116
111
|
end
|
|
117
112
|
|
|
118
|
-
def normalize_string_timestamp(value)
|
|
113
|
+
private def normalize_string_timestamp(value)
|
|
119
114
|
str = value.strip
|
|
120
115
|
return 0 if str.empty?
|
|
121
116
|
|
|
@@ -126,10 +121,10 @@ module SimpleCovMcp
|
|
|
126
121
|
end
|
|
127
122
|
end
|
|
128
123
|
|
|
129
|
-
def log_timestamp_warning(raw_value, error = nil)
|
|
124
|
+
private def log_timestamp_warning(raw_value, error = nil)
|
|
130
125
|
message = "Coverage resultset timestamp could not be parsed: #{raw_value.inspect}"
|
|
131
126
|
message = "#{message} (#{error.message})" if error
|
|
132
|
-
CovUtil.
|
|
127
|
+
CovUtil.safe_log(message)
|
|
133
128
|
end
|
|
134
129
|
end
|
|
135
130
|
end
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'time'
|
|
4
|
-
require 'json'
|
|
5
4
|
require 'pathname'
|
|
6
|
-
require 'set'
|
|
7
5
|
require_relative 'errors'
|
|
8
6
|
require_relative 'util'
|
|
9
7
|
|
|
@@ -66,62 +64,68 @@ module SimpleCovMcp
|
|
|
66
64
|
return if off?
|
|
67
65
|
|
|
68
66
|
ts = coverage_timestamp
|
|
69
|
-
newer = []
|
|
70
|
-
deleted = []
|
|
71
67
|
coverage_files = coverage_map.keys
|
|
72
|
-
coverage_files.each do |abs|
|
|
73
|
-
if File.file?(abs)
|
|
74
|
-
newer << rel(abs) if File.mtime(abs).to_i > ts.to_i
|
|
75
|
-
else
|
|
76
|
-
deleted << rel(abs)
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
68
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
patterns = Array(@tracked_globs).map { |g| File.absolute_path(g, @root) }
|
|
83
|
-
tracked = patterns.flat_map { |p| Dir.glob(p, File::FNM_EXTGLOB | File::FNM_PATHNAME) }
|
|
84
|
-
.select { |p| File.file?(p) }
|
|
85
|
-
covered_set = coverage_files.to_set rescue coverage_files
|
|
86
|
-
tracked.each do |abs|
|
|
87
|
-
missing << rel(abs) unless covered_set.include?(abs)
|
|
88
|
-
end
|
|
89
|
-
end
|
|
69
|
+
newer, deleted = compute_newer_and_deleted_files(coverage_files, ts)
|
|
70
|
+
missing = compute_missing_files(coverage_files)
|
|
90
71
|
|
|
91
|
-
if
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
72
|
+
return if newer.empty? && missing.empty? && deleted.empty?
|
|
73
|
+
|
|
74
|
+
raise CoverageDataProjectStaleError.new(
|
|
75
|
+
nil,
|
|
76
|
+
nil,
|
|
77
|
+
cov_timestamp: ts,
|
|
78
|
+
newer_files: newer,
|
|
79
|
+
missing_files: missing,
|
|
80
|
+
deleted_files: deleted,
|
|
81
|
+
resultset_path: resultset_path
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private def compute_newer_and_deleted_files(coverage_files, timestamp)
|
|
86
|
+
existing, deleted_abs = coverage_files.partition { |abs| File.file?(abs) }
|
|
87
|
+
|
|
88
|
+
newer = existing
|
|
89
|
+
.select { |abs| File.mtime(abs).to_i > timestamp.to_i }
|
|
90
|
+
.map { |abs| rel(abs) }
|
|
91
|
+
deleted = deleted_abs.map { |abs| rel(abs) }
|
|
92
|
+
|
|
93
|
+
[newer, deleted]
|
|
102
94
|
end
|
|
103
95
|
|
|
104
|
-
|
|
96
|
+
# Identifies tracked files that are missing from coverage.
|
|
97
|
+
# Returns array of relative paths for files matched by tracked_globs but not in coverage.
|
|
98
|
+
private def compute_missing_files(coverage_files)
|
|
99
|
+
return [] unless @tracked_globs && Array(@tracked_globs).any?
|
|
100
|
+
|
|
101
|
+
patterns = Array(@tracked_globs).map { |g| File.absolute_path(g, @root) }
|
|
102
|
+
tracked = patterns
|
|
103
|
+
.flat_map { |p| Dir.glob(p, File::FNM_EXTGLOB | File::FNM_PATHNAME) }
|
|
104
|
+
.select { |p| File.file?(p) }
|
|
105
|
+
|
|
106
|
+
covered_set = coverage_files.to_set
|
|
107
|
+
tracked.reject { |abs| covered_set.include?(abs) }.map { |abs| rel(abs) }
|
|
108
|
+
end
|
|
105
109
|
|
|
106
|
-
def coverage_timestamp
|
|
110
|
+
private def coverage_timestamp
|
|
107
111
|
@cov_timestamp || 0
|
|
108
112
|
end
|
|
109
113
|
|
|
110
|
-
def resultset_path
|
|
114
|
+
private def resultset_path
|
|
111
115
|
@resultset_path ||= CovUtil.find_resultset(@root, resultset: @resultset)
|
|
112
|
-
rescue
|
|
116
|
+
rescue
|
|
113
117
|
nil
|
|
114
118
|
end
|
|
115
119
|
|
|
116
|
-
def safe_count_lines(path)
|
|
120
|
+
private def safe_count_lines(path)
|
|
117
121
|
return 0 unless File.file?(path)
|
|
118
122
|
|
|
119
123
|
File.foreach(path).count
|
|
120
|
-
rescue
|
|
124
|
+
rescue
|
|
121
125
|
0
|
|
122
126
|
end
|
|
123
127
|
|
|
124
|
-
def missing_trailing_newline?(path)
|
|
128
|
+
private def missing_trailing_newline?(path)
|
|
125
129
|
return false unless File.file?(path)
|
|
126
130
|
|
|
127
131
|
File.open(path, 'rb') do |f|
|
|
@@ -131,11 +135,11 @@ module SimpleCovMcp
|
|
|
131
135
|
f.seek(-1, IO::SEEK_END)
|
|
132
136
|
f.getbyte != 0x0A
|
|
133
137
|
end
|
|
134
|
-
rescue
|
|
138
|
+
rescue
|
|
135
139
|
false
|
|
136
140
|
end
|
|
137
141
|
|
|
138
|
-
def rel(path)
|
|
142
|
+
private def rel(path)
|
|
139
143
|
# Handle relative vs absolute path mismatches that cause ArgumentError
|
|
140
144
|
Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
|
|
141
145
|
rescue ArgumentError
|
|
@@ -146,7 +150,7 @@ module SimpleCovMcp
|
|
|
146
150
|
# Centralized computation of staleness-related details for a single file.
|
|
147
151
|
# Returns a Hash with keys:
|
|
148
152
|
# :exists, :file_mtime, :coverage_timestamp, :cov_len, :src_len, :newer, :len_mismatch
|
|
149
|
-
def compute_file_staleness_details(file_abs, coverage_lines)
|
|
153
|
+
private def compute_file_staleness_details(file_abs, coverage_lines)
|
|
150
154
|
coverage_ts = coverage_timestamp
|
|
151
155
|
|
|
152
156
|
exists = File.file?(file_abs)
|
|
@@ -164,7 +168,7 @@ module SimpleCovMcp
|
|
|
164
168
|
)
|
|
165
169
|
|
|
166
170
|
# Check if the source file has been modified since coverage was generated
|
|
167
|
-
len_mismatch =
|
|
171
|
+
len_mismatch = length_mismatch?(cov_len, adjusted_src_len)
|
|
168
172
|
newer = check_file_newer_than_coverage(file_mtime, coverage_ts, len_mismatch)
|
|
169
173
|
|
|
170
174
|
{
|
|
@@ -199,7 +203,7 @@ module SimpleCovMcp
|
|
|
199
203
|
# - SimpleCov coverage array length: 3
|
|
200
204
|
# - Missing trailing newline: true
|
|
201
205
|
# - Adjustment: 4 - 1 = 3 (now matches)
|
|
202
|
-
def adjust_line_count_for_missing_newline(file_abs:, exists:, cov_len:, src_len:)
|
|
206
|
+
private def adjust_line_count_for_missing_newline(file_abs:, exists:, cov_len:, src_len:)
|
|
203
207
|
# Only adjust if:
|
|
204
208
|
# 1. File exists (can't check newlines for missing files)
|
|
205
209
|
# 2. Coverage data is present (cov_len > 0)
|
|
@@ -219,7 +223,7 @@ module SimpleCovMcp
|
|
|
219
223
|
#
|
|
220
224
|
# Note: Empty coverage (cov_len == 0) is not considered a mismatch, as it may represent
|
|
221
225
|
# files that were never executed or files that are legitimately empty.
|
|
222
|
-
def
|
|
226
|
+
private def length_mismatch?(cov_len, adjusted_src_len)
|
|
223
227
|
cov_len.positive? && adjusted_src_len != cov_len
|
|
224
228
|
end
|
|
225
229
|
|
|
@@ -233,7 +237,7 @@ module SimpleCovMcp
|
|
|
233
237
|
# The logic: newer &&= !len_mismatch means:
|
|
234
238
|
# - If len_mismatch is true, set newer to false (length mismatch takes precedence)
|
|
235
239
|
# - This way, staleness is categorized as either 'T' (time-based) OR 'L' (length-based), not both
|
|
236
|
-
def check_file_newer_than_coverage(file_mtime, coverage_ts, len_mismatch)
|
|
240
|
+
private def check_file_newer_than_coverage(file_mtime, coverage_ts, len_mismatch)
|
|
237
241
|
newer = !!(file_mtime && file_mtime.to_i > coverage_ts.to_i)
|
|
238
242
|
# If there's a length mismatch, don't also flag as "newer" - the mismatch is more specific
|
|
239
243
|
newer &&= !len_mismatch
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCovMcp
|
|
4
|
+
# General-purpose table formatter with box-drawing characters
|
|
5
|
+
# Used by commands to create consistent formatted output
|
|
6
|
+
class TableFormatter
|
|
7
|
+
# Format data as a table with box-drawing characters
|
|
8
|
+
# @param headers [Array<String>] Column headers
|
|
9
|
+
# @param rows [Array<Array>] Data rows (each row is an array of cell values)
|
|
10
|
+
# @param alignments [Array<Symbol>] Column alignments (:left, :right, :center)
|
|
11
|
+
# @return [String] Formatted table
|
|
12
|
+
def self.format(headers:, rows:, alignments: nil)
|
|
13
|
+
return 'No data to display' if rows.empty?
|
|
14
|
+
|
|
15
|
+
alignments ||= [:left] * headers.size
|
|
16
|
+
all_rows = [headers] + rows.map { |row| row.map(&:to_s) }
|
|
17
|
+
|
|
18
|
+
# Calculate column widths
|
|
19
|
+
widths = headers.size.times.map do |col|
|
|
20
|
+
all_rows.map { |row| row[col].to_s.length }.max
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
lines = []
|
|
24
|
+
lines << border_line(widths, '┌', '┬', '┐')
|
|
25
|
+
lines << data_row(headers, widths, alignments)
|
|
26
|
+
lines << border_line(widths, '├', '┼', '┤')
|
|
27
|
+
rows.each { |row| lines << data_row(row, widths, alignments) }
|
|
28
|
+
lines << border_line(widths, '└', '┴', '┘')
|
|
29
|
+
|
|
30
|
+
lines.join("\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Format a single key-value table (vertical layout)
|
|
34
|
+
# @param data [Hash] Key-value pairs
|
|
35
|
+
# @return [String] Formatted table
|
|
36
|
+
def self.format_vertical(data)
|
|
37
|
+
rows = data.map { |k, v| [k.to_s, v.to_s] }
|
|
38
|
+
format(headers: ['Key', 'Value'], rows: rows, alignments: [:left, :left])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private_class_method def self.border_line(widths, left, mid, right)
|
|
42
|
+
segments = widths.map { |w| '─' * (w + 2) }
|
|
43
|
+
left + segments.join(mid) + right
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private_class_method def self.data_row(cells, widths, alignments)
|
|
47
|
+
formatted = cells.each_with_index.map do |cell, i|
|
|
48
|
+
align_cell(cell.to_s, widths[i], alignments[i])
|
|
49
|
+
end
|
|
50
|
+
"│ #{formatted.join(' │ ')} │"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private_class_method def self.align_cell(content, width, alignment)
|
|
54
|
+
case alignment
|
|
55
|
+
when :right
|
|
56
|
+
content.rjust(width)
|
|
57
|
+
when :center
|
|
58
|
+
content.center(width)
|
|
59
|
+
else # :left
|
|
60
|
+
content.ljust(width)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -14,19 +14,8 @@ module SimpleCovMcp
|
|
|
14
14
|
Output: JSON {"files": [{"file","covered","total","percentage","stale"}, ...], "counts": {"total", "ok", "stale"}} sorted as requested. "stale" is a string ('M', 'T', 'L') or false.
|
|
15
15
|
Examples: "List files with the lowest coverage"; "Show repo coverage sorted descending".
|
|
16
16
|
DESC
|
|
17
|
-
input_schema(
|
|
18
|
-
|
|
19
|
-
additionalProperties: false,
|
|
20
|
-
properties: {
|
|
21
|
-
root: {
|
|
22
|
-
type: 'string',
|
|
23
|
-
description: 'Project root used to resolve relative inputs.',
|
|
24
|
-
default: '.'
|
|
25
|
-
},
|
|
26
|
-
resultset: {
|
|
27
|
-
type: 'string',
|
|
28
|
-
description: 'Path to the SimpleCov .resultset.json file.'
|
|
29
|
-
},
|
|
17
|
+
input_schema(**coverage_schema(
|
|
18
|
+
additional_properties: {
|
|
30
19
|
sort_order: {
|
|
31
20
|
type: 'string',
|
|
32
21
|
description: 'Sort order for coverage percentages.' \
|
|
@@ -34,46 +23,27 @@ module SimpleCovMcp
|
|
|
34
23
|
default: 'ascending',
|
|
35
24
|
enum: ['ascending', 'descending']
|
|
36
25
|
},
|
|
37
|
-
|
|
38
|
-
type: 'string',
|
|
39
|
-
description:
|
|
40
|
-
"How to handle missing/outdated coverage data. 'off' skips checks; 'error' raises.",
|
|
41
|
-
enum: ['off', 'error'],
|
|
42
|
-
default: 'off'
|
|
43
|
-
},
|
|
44
|
-
tracked_globs: {
|
|
45
|
-
type: 'array',
|
|
46
|
-
description: 'Glob patterns for files that should exist in the coverage report' \
|
|
47
|
-
'(helps flag new files).',
|
|
48
|
-
items: { type: 'string' }
|
|
49
|
-
},
|
|
50
|
-
error_mode: {
|
|
51
|
-
type: 'string',
|
|
52
|
-
description:
|
|
53
|
-
"Error handling mode: 'off' (silent), 'on' (log errors), 'trace' (verbose).",
|
|
54
|
-
enum: ['off', 'on', 'trace'],
|
|
55
|
-
default: 'on'
|
|
56
|
-
}
|
|
26
|
+
tracked_globs: TRACKED_GLOBS_PROPERTY
|
|
57
27
|
}
|
|
58
|
-
)
|
|
28
|
+
))
|
|
59
29
|
class << self
|
|
60
|
-
def call(root: '.', resultset: nil, sort_order: 'ascending',
|
|
61
|
-
tracked_globs: nil, error_mode: '
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
30
|
+
def call(root: '.', resultset: nil, sort_order: 'ascending', staleness: :off,
|
|
31
|
+
tracked_globs: nil, error_mode: 'log', server_context:)
|
|
32
|
+
with_error_handling('AllFilesCoverageTool', error_mode: error_mode) do
|
|
33
|
+
# Convert string inputs from MCP to symbols for internal use
|
|
34
|
+
sort_order_sym = sort_order.to_sym
|
|
35
|
+
staleness_sym = staleness.to_sym
|
|
65
36
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
handle_mcp_error(e, 'AllFilesCoverageTool', error_mode: error_mode)
|
|
37
|
+
model = CoverageModel.new(root: root, resultset: resultset, staleness: staleness_sym,
|
|
38
|
+
tracked_globs: tracked_globs)
|
|
39
|
+
presenter = Presenters::ProjectCoveragePresenter.new(
|
|
40
|
+
model: model,
|
|
41
|
+
sort_order: sort_order_sym,
|
|
42
|
+
check_stale: (staleness_sym == :error),
|
|
43
|
+
tracked_globs: tracked_globs
|
|
44
|
+
)
|
|
45
|
+
respond_json(presenter.relativized_payload, name: 'all_files_coverage.json')
|
|
46
|
+
end
|
|
77
47
|
end
|
|
78
48
|
end
|
|
79
49
|
end
|
|
@@ -10,18 +10,24 @@ module SimpleCovMcp
|
|
|
10
10
|
description <<~DESC
|
|
11
11
|
Use this when the user needs per-line coverage data for a single file.
|
|
12
12
|
Do not use this for high-level counts; coverage.summary is cheaper for aggregate numbers.
|
|
13
|
-
Inputs: file path (required) plus optional root/resultset/
|
|
13
|
+
Inputs: file path (required) plus optional root/resultset/staleness mode inherited from BaseTool.
|
|
14
14
|
Output: JSON object with "file", "lines" => [{"line": 12, "hits": 0, "covered": false}], plus "summary" with totals and "stale" status.
|
|
15
15
|
Example: "Show detailed coverage for lib/simple_cov_mcp/model.rb".
|
|
16
16
|
DESC
|
|
17
17
|
input_schema(**input_schema_def)
|
|
18
18
|
class << self
|
|
19
|
-
def call(path:, root: '.', resultset: nil,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
def call(path:, root: '.', resultset: nil, staleness: :off, error_mode: 'log',
|
|
20
|
+
server_context:)
|
|
21
|
+
with_error_handling('CoverageDetailedTool', error_mode: error_mode) do
|
|
22
|
+
model = CoverageModel.new(
|
|
23
|
+
root: root,
|
|
24
|
+
resultset: resultset,
|
|
25
|
+
staleness: staleness.to_sym
|
|
26
|
+
)
|
|
27
|
+
presenter = Presenters::CoverageDetailedPresenter.new(model: model, path: path)
|
|
28
|
+
respond_json(presenter.relativized_payload, name: 'coverage_detailed.json',
|
|
29
|
+
pretty: true)
|
|
30
|
+
end
|
|
25
31
|
end
|
|
26
32
|
end
|
|
27
33
|
end
|
|
@@ -10,18 +10,23 @@ module SimpleCovMcp
|
|
|
10
10
|
description <<~DESC
|
|
11
11
|
Use this when you need the raw SimpleCov `lines` array for a file exactly as stored on disk.
|
|
12
12
|
Do not use this for human-friendly explanations; choose coverage.detailed or coverage.summary instead.
|
|
13
|
-
Inputs: file path (required) plus optional root/resultset/
|
|
13
|
+
Inputs: file path (required) plus optional root/resultset/staleness mode inherited from BaseTool.
|
|
14
14
|
Output: JSON object with "file" and "lines" (array of integers/nulls) mirroring SimpleCov's native structure, plus "stale" status.
|
|
15
15
|
Example: "Fetch the raw coverage array for spec/support/foo_helper.rb".
|
|
16
16
|
DESC
|
|
17
17
|
input_schema(**input_schema_def)
|
|
18
18
|
class << self
|
|
19
|
-
def call(path:, root: '.', resultset: nil,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
def call(path:, root: '.', resultset: nil, staleness: :off, error_mode: 'log',
|
|
20
|
+
server_context:)
|
|
21
|
+
with_error_handling('CoverageRawTool', error_mode: error_mode) do
|
|
22
|
+
model = CoverageModel.new(
|
|
23
|
+
root: root,
|
|
24
|
+
resultset: resultset,
|
|
25
|
+
staleness: staleness.to_sym
|
|
26
|
+
)
|
|
27
|
+
presenter = Presenters::CoverageRawPresenter.new(model: model, path: path)
|
|
28
|
+
respond_json(presenter.relativized_payload, name: 'coverage_raw.json', pretty: true)
|
|
29
|
+
end
|
|
25
30
|
end
|
|
26
31
|
end
|
|
27
32
|
end
|