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
@@ -1,176 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module SimpleCovMcp
|
4
|
-
# Base error class for all SimpleCov MCP errors
|
5
|
-
class Error < StandardError
|
6
|
-
attr_reader :original_error
|
7
|
-
|
8
|
-
def initialize(message = nil, original_error = nil)
|
9
|
-
@original_error = original_error
|
10
|
-
super(message)
|
11
|
-
end
|
12
|
-
|
13
|
-
def user_friendly_message
|
14
|
-
message
|
15
|
-
end
|
16
|
-
end
|
17
|
-
|
18
|
-
# Configuration or setup related errors
|
19
|
-
class ConfigurationError < Error
|
20
|
-
def user_friendly_message
|
21
|
-
"Configuration error: #{message}"
|
22
|
-
end
|
23
|
-
end
|
24
|
-
|
25
|
-
# File or path related errors
|
26
|
-
class FileError < Error
|
27
|
-
def user_friendly_message
|
28
|
-
"File error: #{message}"
|
29
|
-
end
|
30
|
-
end
|
31
|
-
|
32
|
-
# More specific file errors
|
33
|
-
class FileNotFoundError < FileError; end
|
34
|
-
class FilePermissionError < FileError; end
|
35
|
-
class NotAFileError < FileError; end
|
36
|
-
class ResultsetNotFoundError < FileError; end
|
37
|
-
|
38
|
-
# Coverage data related errors
|
39
|
-
class CoverageDataError < Error
|
40
|
-
def user_friendly_message
|
41
|
-
"Coverage data error: #{message}"
|
42
|
-
end
|
43
|
-
end
|
44
|
-
|
45
|
-
# Coverage data is present but appears stale compared to source files
|
46
|
-
class CoverageDataStaleError < CoverageDataError
|
47
|
-
attr_reader :file_path, :file_mtime, :cov_timestamp, :src_len, :cov_len, :resultset_path
|
48
|
-
|
49
|
-
def initialize(message = nil, original_error = nil, file_path: nil, file_mtime: nil, cov_timestamp: nil, src_len: nil, cov_len: nil, resultset_path: nil)
|
50
|
-
super(message, original_error)
|
51
|
-
@file_path = file_path
|
52
|
-
@file_mtime = file_mtime
|
53
|
-
@cov_timestamp = cov_timestamp
|
54
|
-
@src_len = src_len
|
55
|
-
@cov_len = cov_len
|
56
|
-
@resultset_path = resultset_path
|
57
|
-
end
|
58
|
-
|
59
|
-
def user_friendly_message
|
60
|
-
base = "Coverage data stale: #{message || default_message}"
|
61
|
-
base + build_details
|
62
|
-
end
|
63
|
-
|
64
|
-
private
|
65
|
-
|
66
|
-
def default_message
|
67
|
-
fp = file_path || 'file'
|
68
|
-
"Coverage data appears stale for #{fp}"
|
69
|
-
end
|
70
|
-
|
71
|
-
def build_details
|
72
|
-
file_utc, file_local = format_time_both(@file_mtime)
|
73
|
-
cov_utc, cov_local = format_epoch_both(@cov_timestamp)
|
74
|
-
delta_str = format_delta_seconds(@file_mtime, @cov_timestamp)
|
75
|
-
details = []
|
76
|
-
details << "\nFile - time: #{file_utc || 'not found'} (local #{file_local || 'n/a'}), lines: #{@src_len}"
|
77
|
-
details << "\nCoverage - time: #{cov_utc || 'not found'} (local #{cov_local || 'n/a'}), lines: #{@cov_len}"
|
78
|
-
details << "\nDelta - file is #{delta_str} newer than coverage" if delta_str
|
79
|
-
details << "\nResultset - #{@resultset_path}" if @resultset_path
|
80
|
-
details.join
|
81
|
-
end
|
82
|
-
|
83
|
-
def format_epoch_both(epoch_seconds)
|
84
|
-
return [nil, nil] unless epoch_seconds
|
85
|
-
t = Time.at(epoch_seconds.to_i)
|
86
|
-
[t.utc.iso8601, t.getlocal.iso8601]
|
87
|
-
rescue StandardError
|
88
|
-
[epoch_seconds.to_s, epoch_seconds.to_s]
|
89
|
-
end
|
90
|
-
|
91
|
-
def format_time_both(time)
|
92
|
-
return [nil, nil] unless time
|
93
|
-
t = time.is_a?(Time) ? time : Time.parse(time.to_s)
|
94
|
-
[t.utc.iso8601, t.getlocal.iso8601]
|
95
|
-
rescue StandardError
|
96
|
-
[time.to_s, time.to_s]
|
97
|
-
end
|
98
|
-
|
99
|
-
def format_delta_seconds(file_mtime, cov_timestamp)
|
100
|
-
return nil unless file_mtime && cov_timestamp
|
101
|
-
seconds = file_mtime.to_i - cov_timestamp.to_i
|
102
|
-
sign = seconds >= 0 ? '+' : '-'
|
103
|
-
"#{sign}#{seconds.abs}s"
|
104
|
-
rescue StandardError
|
105
|
-
nil
|
106
|
-
end
|
107
|
-
end
|
108
|
-
|
109
|
-
# Project-level stale coverage (global) — coverage timestamp older than
|
110
|
-
# one or more source files, or new tracked files missing from coverage.
|
111
|
-
class CoverageDataProjectStaleError < CoverageDataError
|
112
|
-
attr_reader :cov_timestamp, :newer_files, :missing_files, :deleted_files, :resultset_path
|
113
|
-
|
114
|
-
def initialize(message = nil, original_error = nil, cov_timestamp: nil, newer_files: [], missing_files: [], deleted_files: [], resultset_path: nil)
|
115
|
-
super(message, original_error)
|
116
|
-
@cov_timestamp = cov_timestamp
|
117
|
-
@newer_files = Array(newer_files)
|
118
|
-
@missing_files = Array(missing_files)
|
119
|
-
@deleted_files = Array(deleted_files)
|
120
|
-
@resultset_path = resultset_path
|
121
|
-
end
|
122
|
-
|
123
|
-
def user_friendly_message
|
124
|
-
base = "Coverage data stale (project): #{message || default_message}"
|
125
|
-
base + build_details
|
126
|
-
end
|
127
|
-
|
128
|
-
private
|
129
|
-
|
130
|
-
def default_message
|
131
|
-
'Coverage data appears stale for project'
|
132
|
-
end
|
133
|
-
|
134
|
-
def build_details
|
135
|
-
cov_utc, cov_local = format_epoch_both(@cov_timestamp)
|
136
|
-
parts = []
|
137
|
-
parts << "\nCoverage - time: #{cov_utc || 'not found'} (local #{cov_local || 'n/a'})"
|
138
|
-
unless @newer_files.empty?
|
139
|
-
parts << "\nNewer files (#{@newer_files.size}):"
|
140
|
-
parts.concat(@newer_files.first(10).map { |f| " - #{f}" })
|
141
|
-
parts << " ..." if @newer_files.size > 10
|
142
|
-
end
|
143
|
-
unless @missing_files.empty?
|
144
|
-
parts << "\nMissing files (new in project, not in coverage, #{@missing_files.size}):"
|
145
|
-
parts.concat(@missing_files.first(10).map { |f| " - #{f}" })
|
146
|
-
parts << " ..." if @missing_files.size > 10
|
147
|
-
end
|
148
|
-
unless @deleted_files.empty?
|
149
|
-
parts << "\nCoverage-only files (deleted or moved in project, #{@deleted_files.size}):"
|
150
|
-
parts.concat(@deleted_files.first(10).map { |f| " - #{f}" })
|
151
|
-
parts << " ..." if @deleted_files.size > 10
|
152
|
-
end
|
153
|
-
parts << "\nResultset - #{@resultset_path}" if @resultset_path
|
154
|
-
parts.join
|
155
|
-
end
|
156
|
-
|
157
|
-
def format_epoch_both(epoch_seconds)
|
158
|
-
return [nil, nil] unless epoch_seconds
|
159
|
-
t = Time.at(epoch_seconds.to_i)
|
160
|
-
[t.utc.iso8601, t.getlocal.iso8601]
|
161
|
-
rescue StandardError
|
162
|
-
[epoch_seconds.to_s, epoch_seconds.to_s]
|
163
|
-
end
|
164
|
-
end
|
165
|
-
|
166
|
-
# Command line usage errors
|
167
|
-
class UsageError < Error
|
168
|
-
def self.for_subcommand(usage_fragment)
|
169
|
-
new("Usage: simplecov-mcp #{usage_fragment}")
|
170
|
-
end
|
171
|
-
|
172
|
-
def user_friendly_message
|
173
|
-
message
|
174
|
-
end
|
175
|
-
end
|
176
|
-
end
|
@@ -1,30 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module SimpleCovMcp
|
4
|
-
class MCPServer
|
5
|
-
def initialize
|
6
|
-
# Configure error handling for MCP server mode using the factory
|
7
|
-
SimpleCovMcp.error_handler = ErrorHandlerFactory.for_mcp_server
|
8
|
-
end
|
9
|
-
|
10
|
-
def run
|
11
|
-
tools = [
|
12
|
-
Tools::AllFilesCoverageTool,
|
13
|
-
Tools::CoverageDetailedTool,
|
14
|
-
Tools::CoverageRawTool,
|
15
|
-
Tools::CoverageSummaryTool,
|
16
|
-
Tools::UncoveredLinesTool,
|
17
|
-
Tools::CoverageTableTool,
|
18
|
-
Tools::HelpTool,
|
19
|
-
Tools::VersionTool
|
20
|
-
]
|
21
|
-
|
22
|
-
server = ::MCP::Server.new(
|
23
|
-
name: 'simplecov-mcp',
|
24
|
-
version: SimpleCovMcp::VERSION,
|
25
|
-
tools: tools
|
26
|
-
)
|
27
|
-
::MCP::Server::Transports::StdioTransport.new(server).open
|
28
|
-
end
|
29
|
-
end
|
30
|
-
end
|
data/lib/simple_cov_mcp/model.rb
DELETED
@@ -1,104 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require_relative 'util'
|
4
|
-
require_relative 'errors'
|
5
|
-
require_relative 'staleness_checker'
|
6
|
-
|
7
|
-
module SimpleCovMcp
|
8
|
-
class CoverageModel
|
9
|
-
# Create a CoverageModel
|
10
|
-
#
|
11
|
-
# Params:
|
12
|
-
# - root: project root directory (default '.')
|
13
|
-
# - resultset: path or directory to .resultset.json
|
14
|
-
# - staleness: 'off' or 'error' (default 'off'). When 'error', raises
|
15
|
-
# stale errors if sources are newer than coverage or line counts mismatch.
|
16
|
-
# - tracked_globs: only used for all_files project-level staleness.
|
17
|
-
def initialize(root: '.', resultset: nil, staleness: 'off', tracked_globs: nil)
|
18
|
-
@root = File.absolute_path(root || '.')
|
19
|
-
@resultset = resultset
|
20
|
-
@checker = StalenessChecker.new(root: @root, resultset: @resultset, mode: staleness, tracked_globs: tracked_globs)
|
21
|
-
begin
|
22
|
-
@cov = CovUtil.load_latest_coverage(@root, resultset: resultset)
|
23
|
-
rescue Errno::ENOENT => e
|
24
|
-
raise FileError.new("Coverage data not found at #{resultset || @root}")
|
25
|
-
rescue JSON::ParserError => e
|
26
|
-
raise CoverageDataError.new("Invalid coverage data format")
|
27
|
-
rescue => e
|
28
|
-
raise CoverageDataError.new("Failed to load coverage data: #{e.message}")
|
29
|
-
end
|
30
|
-
end
|
31
|
-
|
32
|
-
# Returns { 'file' => <absolute_path>, 'lines' => [hits|nil,...] }
|
33
|
-
def raw_for(path)
|
34
|
-
file_abs, coverage_lines = resolve(path)
|
35
|
-
{ 'file' => file_abs, 'lines' => coverage_lines }
|
36
|
-
end
|
37
|
-
|
38
|
-
# Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, 'pct'=>} }
|
39
|
-
def summary_for(path)
|
40
|
-
file_abs, coverage_lines = resolve(path)
|
41
|
-
{ 'file' => file_abs, 'summary' => CovUtil.summary(coverage_lines) }
|
42
|
-
end
|
43
|
-
|
44
|
-
# Returns { 'file' => <absolute_path>, 'uncovered' => [line,...], 'summary' => {...} }
|
45
|
-
def uncovered_for(path)
|
46
|
-
file_abs, coverage_lines = resolve(path)
|
47
|
-
{ 'file' => file_abs, 'uncovered' => CovUtil.uncovered(coverage_lines), 'summary' => CovUtil.summary(coverage_lines) }
|
48
|
-
end
|
49
|
-
|
50
|
-
# Returns { 'file' => <absolute_path>, 'lines' => [{'line'=>,'hits'=>,'covered'=>},...], 'summary' => {...} }
|
51
|
-
def detailed_for(path)
|
52
|
-
file_abs, coverage_lines = resolve(path)
|
53
|
-
{ 'file' => file_abs, 'lines' => CovUtil.detailed(coverage_lines), 'summary' => CovUtil.summary(coverage_lines) }
|
54
|
-
end
|
55
|
-
|
56
|
-
# Returns [ { 'file' =>, 'covered' =>, 'total' =>, 'percentage' =>, 'stale' => }, ... ]
|
57
|
-
def all_files(sort_order: :ascending, check_stale: !@checker.off?, tracked_globs: nil)
|
58
|
-
stale_checker = StalenessChecker.new(root: @root, resultset: @resultset, mode: 'off', tracked_globs: tracked_globs)
|
59
|
-
rows = @cov.map do |abs_path, data|
|
60
|
-
next unless data['lines'].is_a?(Array)
|
61
|
-
s = CovUtil.summary(data['lines'])
|
62
|
-
stale = stale_checker.stale_for_file?(abs_path, data['lines'])
|
63
|
-
{ 'file' => abs_path, 'covered' => s['covered'], 'total' => s['total'], 'percentage' => s['pct'], 'stale' => stale }
|
64
|
-
end.compact
|
65
|
-
|
66
|
-
if check_stale
|
67
|
-
StalenessChecker.new(root: @root, resultset: @resultset, mode: 'error', tracked_globs: tracked_globs)
|
68
|
-
.check_project!(@cov)
|
69
|
-
end
|
70
|
-
|
71
|
-
rows.sort! do |a, b|
|
72
|
-
pct_cmp = (sort_order.to_s == 'descending') ? (b['percentage'] <=> a['percentage']) : (a['percentage'] <=> b['percentage'])
|
73
|
-
pct_cmp == 0 ? (a['file'] <=> b['file']) : pct_cmp
|
74
|
-
end
|
75
|
-
rows
|
76
|
-
end
|
77
|
-
|
78
|
-
private
|
79
|
-
|
80
|
-
def resolve(path)
|
81
|
-
file_abs = File.absolute_path(path, @root)
|
82
|
-
coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
|
83
|
-
@checker.check_file!(file_abs, coverage_lines) unless @checker.off?
|
84
|
-
if coverage_lines.nil?
|
85
|
-
raise FileError.new("No coverage data found for file: #{path}")
|
86
|
-
end
|
87
|
-
[file_abs, coverage_lines]
|
88
|
-
rescue Errno::ENOENT => e
|
89
|
-
raise FileNotFoundError.new("File not found: #{path}")
|
90
|
-
end
|
91
|
-
|
92
|
-
# staleness handled by StalenessChecker
|
93
|
-
|
94
|
-
def check_all_files_staleness!(cov_timestamp, tracked_globs: nil)
|
95
|
-
# handled by StalenessChecker
|
96
|
-
end
|
97
|
-
|
98
|
-
def rel_to_root(path)
|
99
|
-
Pathname.new(path).relative_path_from(Pathname.new(File.absolute_path(@root))).to_s
|
100
|
-
end
|
101
|
-
|
102
|
-
# Detailed stale message construction moved to CoverageDataStaleError
|
103
|
-
end
|
104
|
-
end
|
@@ -1,125 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'time'
|
4
|
-
|
5
|
-
module SimpleCovMcp
|
6
|
-
# Lightweight service object to check staleness of coverage vs. sources
|
7
|
-
class StalenessChecker
|
8
|
-
MODES = %w[off error].freeze
|
9
|
-
|
10
|
-
def initialize(root:, resultset:, mode: 'off', tracked_globs: nil)
|
11
|
-
@root = File.absolute_path(root || '.')
|
12
|
-
@resultset = resultset
|
13
|
-
@mode = (mode || 'off').to_s
|
14
|
-
@tracked_globs = tracked_globs
|
15
|
-
@cov_timestamp = nil
|
16
|
-
@resultset_path = nil
|
17
|
-
end
|
18
|
-
|
19
|
-
def off?
|
20
|
-
@mode == 'off'
|
21
|
-
end
|
22
|
-
|
23
|
-
# Raise CoverageDataStaleError if stale (only in error mode)
|
24
|
-
def check_file!(file_abs, coverage_lines)
|
25
|
-
return if off?
|
26
|
-
ts = coverage_timestamp
|
27
|
-
fm = File.mtime(file_abs) if File.file?(file_abs)
|
28
|
-
cov_len = coverage_lines.respond_to?(:length) ? coverage_lines.length : 0
|
29
|
-
src_len = safe_count_lines(file_abs)
|
30
|
-
if (fm && fm.to_i > ts.to_i) || (cov_len.positive? && src_len != cov_len)
|
31
|
-
raise CoverageDataStaleError.new(
|
32
|
-
nil,
|
33
|
-
nil,
|
34
|
-
file_path: rel(file_abs),
|
35
|
-
file_mtime: fm,
|
36
|
-
cov_timestamp: ts,
|
37
|
-
src_len: src_len,
|
38
|
-
cov_len: cov_len,
|
39
|
-
resultset_path: resultset_path
|
40
|
-
)
|
41
|
-
end
|
42
|
-
end
|
43
|
-
|
44
|
-
# Compute whether a specific file appears stale relative to coverage.
|
45
|
-
# Ignores mode and never raises; returns true when:
|
46
|
-
# - the file is missing/deleted, or
|
47
|
-
# - the file mtime is newer than the coverage timestamp, or
|
48
|
-
# - the source line count differs from the coverage lines array length (when present).
|
49
|
-
def stale_for_file?(file_abs, coverage_lines)
|
50
|
-
ts = coverage_timestamp
|
51
|
-
return true unless File.file?(file_abs)
|
52
|
-
|
53
|
-
fm = File.mtime(file_abs)
|
54
|
-
cov_len = coverage_lines.respond_to?(:length) ? coverage_lines.length : 0
|
55
|
-
src_len = safe_count_lines(file_abs)
|
56
|
-
(fm && fm.to_i > ts.to_i) || (cov_len.positive? && src_len != cov_len)
|
57
|
-
rescue StandardError
|
58
|
-
# Be conservative: if we cannot determine, mark as stale
|
59
|
-
true
|
60
|
-
end
|
61
|
-
|
62
|
-
# Raise CoverageDataProjectStaleError if any covered file is newer or if
|
63
|
-
# tracked files are missing from coverage, or coverage includes deleted files.
|
64
|
-
def check_project!(coverage_map)
|
65
|
-
return if off?
|
66
|
-
ts = coverage_timestamp
|
67
|
-
newer = []
|
68
|
-
deleted = []
|
69
|
-
coverage_files = coverage_map.keys
|
70
|
-
coverage_files.each do |abs|
|
71
|
-
if File.file?(abs)
|
72
|
-
newer << rel(abs) if File.mtime(abs).to_i > ts.to_i
|
73
|
-
else
|
74
|
-
deleted << rel(abs)
|
75
|
-
end
|
76
|
-
end
|
77
|
-
|
78
|
-
missing = []
|
79
|
-
if @tracked_globs && !Array(@tracked_globs).empty?
|
80
|
-
patterns = Array(@tracked_globs).map { |g| File.absolute_path(g, @root) }
|
81
|
-
tracked = patterns.flat_map { |p| Dir.glob(p, File::FNM_EXTGLOB | File::FNM_PATHNAME) }
|
82
|
-
.select { |p| File.file?(p) }
|
83
|
-
covered_set = coverage_files.to_set rescue coverage_files
|
84
|
-
tracked.each do |abs|
|
85
|
-
missing << rel(abs) unless covered_set.include?(abs)
|
86
|
-
end
|
87
|
-
end
|
88
|
-
|
89
|
-
if !newer.empty? || !missing.empty? || !deleted.empty?
|
90
|
-
raise CoverageDataProjectStaleError.new(
|
91
|
-
nil,
|
92
|
-
nil,
|
93
|
-
cov_timestamp: ts,
|
94
|
-
newer_files: newer,
|
95
|
-
missing_files: missing,
|
96
|
-
deleted_files: deleted,
|
97
|
-
resultset_path: resultset_path
|
98
|
-
)
|
99
|
-
end
|
100
|
-
end
|
101
|
-
|
102
|
-
private
|
103
|
-
|
104
|
-
def coverage_timestamp
|
105
|
-
@cov_timestamp ||= CovUtil.latest_timestamp(@root, resultset: @resultset)
|
106
|
-
end
|
107
|
-
|
108
|
-
def resultset_path
|
109
|
-
@resultset_path ||= CovUtil.find_resultset(@root, resultset: @resultset)
|
110
|
-
rescue StandardError
|
111
|
-
nil
|
112
|
-
end
|
113
|
-
|
114
|
-
def safe_count_lines(path)
|
115
|
-
return 0 unless File.file?(path)
|
116
|
-
File.foreach(path).count
|
117
|
-
rescue StandardError
|
118
|
-
0
|
119
|
-
end
|
120
|
-
|
121
|
-
def rel(path)
|
122
|
-
Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
|
123
|
-
end
|
124
|
-
end
|
125
|
-
end
|
@@ -1,61 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'stringio'
|
4
|
-
require_relative '../cli'
|
5
|
-
require_relative '../base_tool'
|
6
|
-
|
7
|
-
module SimpleCovMcp
|
8
|
-
module Tools
|
9
|
-
class CoverageTableTool < BaseTool
|
10
|
-
description <<~DESC
|
11
|
-
Use this when a user wants the plain text coverage table exactly like `simplecov-mcp --table` would print (no ANSI colors).
|
12
|
-
Do not use this for machine-readable data; coverage.all_files returns structured JSON.
|
13
|
-
Inputs: optional project root/resultset path/sort order/staleness mode matching the CLI flags.
|
14
|
-
Output: text block containing the formatted coverage table with headers and percentages.
|
15
|
-
Example: "Show me the CLI coverage table sorted descending".
|
16
|
-
DESC
|
17
|
-
input_schema(
|
18
|
-
type: 'object',
|
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
|
-
},
|
30
|
-
sort_order: {
|
31
|
-
type: 'string',
|
32
|
-
description: "Sort order for the printed coverage table (ascending or descending).",
|
33
|
-
default: 'ascending',
|
34
|
-
enum: ['ascending', 'descending']
|
35
|
-
},
|
36
|
-
stale: {
|
37
|
-
type: 'string',
|
38
|
-
description: "How to handle missing/outdated coverage data. 'off' skips checks; 'error' raises.",
|
39
|
-
enum: ['off', 'error'],
|
40
|
-
default: 'off'
|
41
|
-
}
|
42
|
-
}
|
43
|
-
)
|
44
|
-
|
45
|
-
class << self
|
46
|
-
def call(root: '.', resultset: nil, sort_order: 'ascending', stale: 'off', server_context:)
|
47
|
-
# Capture the output of the CLI's table report while honoring CLI options
|
48
|
-
output = StringIO.new
|
49
|
-
cli = CoverageCLI.new
|
50
|
-
cli.instance_variable_set(:@root, root || '.')
|
51
|
-
cli.instance_variable_set(:@resultset, resultset)
|
52
|
-
cli.instance_variable_set(:@stale_mode, (stale || 'off').to_s)
|
53
|
-
cli.show_default_report(sort_order: sort_order.to_sym, output: output)
|
54
|
-
::MCP::Tool::Response.new([{ type: 'text', text: output.string }])
|
55
|
-
rescue => e
|
56
|
-
handle_mcp_error(e, 'CoverageTableTool')
|
57
|
-
end
|
58
|
-
end
|
59
|
-
end
|
60
|
-
end
|
61
|
-
end
|
data/lib/simple_cov_mcp/util.rb
DELETED
@@ -1,122 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module SimpleCovMcp
|
4
|
-
RESULTSET_CANDIDATES = [
|
5
|
-
'.resultset.json',
|
6
|
-
'coverage/.resultset.json',
|
7
|
-
'tmp/.resultset.json'
|
8
|
-
].freeze
|
9
|
-
|
10
|
-
module CovUtil
|
11
|
-
module_function
|
12
|
-
|
13
|
-
|
14
|
-
def log(msg)
|
15
|
-
path = File.expand_path('~/simplecov_mcp.log')
|
16
|
-
File.open(path, 'a') { |f| f.puts "[#{Time.now.iso8601}] #{msg}" }
|
17
|
-
rescue StandardError
|
18
|
-
# ignore logging failures
|
19
|
-
end
|
20
|
-
|
21
|
-
def find_resultset(root, resultset: nil)
|
22
|
-
if resultset && !resultset.to_s.empty?
|
23
|
-
path = File.absolute_path(resultset, root)
|
24
|
-
if (resolved = resolve_resultset_candidate(path, strict: true))
|
25
|
-
return resolved
|
26
|
-
end
|
27
|
-
end
|
28
|
-
|
29
|
-
if (env = ENV['SIMPLECOV_RESULTSET']) && !env.empty?
|
30
|
-
path = File.absolute_path(env, root)
|
31
|
-
if (resolved = resolve_resultset_candidate(path, strict: false))
|
32
|
-
return resolved
|
33
|
-
end
|
34
|
-
end
|
35
|
-
RESULTSET_CANDIDATES
|
36
|
-
.map { |p| File.absolute_path(p, root) }
|
37
|
-
.find { |p| File.file?(p) } or
|
38
|
-
raise "Could not find .resultset.json under #{root.inspect}; run tests or set SIMPLECOV_RESULTSET"
|
39
|
-
end
|
40
|
-
|
41
|
-
# returns { abs_path => {'lines' => [hits|nil,...]} }
|
42
|
-
def load_latest_coverage(root, resultset: nil)
|
43
|
-
rs = find_resultset(root, resultset: resultset)
|
44
|
-
raw = JSON.parse(File.read(rs))
|
45
|
-
_suite, data = raw.max_by { |_k, v| (v['timestamp'] || v['created_at'] || 0).to_i }
|
46
|
-
cov = data['coverage'] or raise "No 'coverage' key found in resultset file: #{rs}"
|
47
|
-
cov.transform_keys { |k| File.absolute_path(k, root) }
|
48
|
-
end
|
49
|
-
|
50
|
-
# Returns the timestamp (Integer seconds) for the latest coverage entry
|
51
|
-
# in the resultset. Used for staleness checks against source mtimes.
|
52
|
-
def latest_timestamp(root, resultset: nil)
|
53
|
-
rs = find_resultset(root, resultset: resultset)
|
54
|
-
raw = JSON.parse(File.read(rs))
|
55
|
-
_suite, data = raw.max_by { |_k, v| (v['timestamp'] || v['created_at'] || 0).to_i }
|
56
|
-
(data['timestamp'] || data['created_at'] || 0).to_i
|
57
|
-
end
|
58
|
-
|
59
|
-
def resolve_resultset_candidate(path, strict:)
|
60
|
-
return path if File.file?(path)
|
61
|
-
if File.directory?(path)
|
62
|
-
candidate = File.join(path, '.resultset.json')
|
63
|
-
return candidate if File.file?(candidate)
|
64
|
-
raise "No .resultset.json found in directory: #{path}" if strict
|
65
|
-
return nil
|
66
|
-
end
|
67
|
-
raise "Specified resultset not found: #{path}" if strict
|
68
|
-
nil
|
69
|
-
end
|
70
|
-
|
71
|
-
def lookup_lines(cov, file_abs)
|
72
|
-
if (h = cov[file_abs]) && h['lines'].is_a?(Array)
|
73
|
-
return h['lines']
|
74
|
-
end
|
75
|
-
|
76
|
-
# try without current working directory prefix
|
77
|
-
cwd = Dir.pwd
|
78
|
-
without = file_abs.sub(/\A#{Regexp.escape(cwd)}\//, '')
|
79
|
-
if (h = cov[without]) && h['lines'].is_a?(Array)
|
80
|
-
return h['lines']
|
81
|
-
end
|
82
|
-
|
83
|
-
# fallback: basename match
|
84
|
-
base = File.basename(file_abs)
|
85
|
-
kv = cov.find { |k, v| File.basename(k) == base && v['lines'].is_a?(Array) }
|
86
|
-
kv and return kv[1]['lines']
|
87
|
-
|
88
|
-
raise "No coverage entry found for #{file_abs}"
|
89
|
-
end
|
90
|
-
|
91
|
-
def summary(arr)
|
92
|
-
total = 0
|
93
|
-
covered = 0
|
94
|
-
arr.each do |hits|
|
95
|
-
next if hits.nil?
|
96
|
-
total += 1
|
97
|
-
covered += 1 if hits.to_i > 0
|
98
|
-
end
|
99
|
-
pct = total.zero? ? 100.0 : ((covered.to_f * 100.0 / total) * 100).round / 100.0
|
100
|
-
{ 'covered' => covered, 'total' => total, 'pct' => pct }
|
101
|
-
end
|
102
|
-
|
103
|
-
def uncovered(arr)
|
104
|
-
out = []
|
105
|
-
arr.each_with_index do |hits, i|
|
106
|
-
next if hits.nil?
|
107
|
-
out << (i + 1) if hits.to_i.zero?
|
108
|
-
end
|
109
|
-
out
|
110
|
-
end
|
111
|
-
|
112
|
-
def detailed(arr)
|
113
|
-
rows = []
|
114
|
-
arr.each_with_index do |hits, i|
|
115
|
-
next if hits.nil?
|
116
|
-
h = hits.to_i
|
117
|
-
rows << { 'line' => i + 1, 'hits' => h, 'covered' => h.positive? }
|
118
|
-
end
|
119
|
-
rows
|
120
|
-
end
|
121
|
-
end
|
122
|
-
end
|