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,124 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require_relative 'errors'
|
5
|
+
require_relative 'util'
|
6
|
+
|
7
|
+
module SimpleCovMcp
|
8
|
+
# Handles error reporting and logging with configurable behavior
|
9
|
+
class ErrorHandler
|
10
|
+
attr_accessor :error_mode, :logger
|
11
|
+
|
12
|
+
VALID_ERROR_MODES = [:off, :on, :trace].freeze
|
13
|
+
|
14
|
+
def initialize(error_mode: :on, logger: nil)
|
15
|
+
unless VALID_ERROR_MODES.include?(error_mode)
|
16
|
+
raise ArgumentError, "Invalid error_mode: #{error_mode.inspect}. Valid modes: #{VALID_ERROR_MODES.inspect}"
|
17
|
+
end
|
18
|
+
|
19
|
+
@error_mode = error_mode
|
20
|
+
@logger = logger
|
21
|
+
end
|
22
|
+
|
23
|
+
def log_errors?
|
24
|
+
error_mode != :off
|
25
|
+
end
|
26
|
+
|
27
|
+
def show_stack_traces?
|
28
|
+
error_mode == :trace
|
29
|
+
end
|
30
|
+
|
31
|
+
# Handle an error with appropriate logging and re-raising behavior
|
32
|
+
def handle_error(error, context: nil, reraise: true)
|
33
|
+
log_error(error, context)
|
34
|
+
if reraise
|
35
|
+
raise error.is_a?(SimpleCovMcp::Error) ? error : convert_standard_error(error)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Convert standard Ruby errors to user-friendly custom errors
|
40
|
+
def convert_standard_error(error)
|
41
|
+
case error
|
42
|
+
when Errno::ENOENT
|
43
|
+
filename = extract_filename(error.message)
|
44
|
+
FileNotFoundError.new("File not found: #{filename}", error)
|
45
|
+
when Errno::EACCES
|
46
|
+
filename = extract_filename(error.message)
|
47
|
+
FilePermissionError.new("Permission denied accessing file: #{filename}", error)
|
48
|
+
when Errno::EISDIR
|
49
|
+
filename = extract_filename(error.message)
|
50
|
+
NotAFileError.new("Expected file but found directory: #{filename}", error)
|
51
|
+
when JSON::ParserError
|
52
|
+
CoverageDataError.new("Invalid coverage data format - JSON parsing failed: #{error.message}", error)
|
53
|
+
when ArgumentError
|
54
|
+
if error.message.include?('wrong number of arguments')
|
55
|
+
UsageError.new("Invalid number of arguments: #{error.message}", error)
|
56
|
+
else
|
57
|
+
ConfigurationError.new("Invalid configuration: #{error.message}", error)
|
58
|
+
end
|
59
|
+
when NoMethodError
|
60
|
+
method_info = extract_method_info(error.message)
|
61
|
+
CoverageDataError.new("Invalid coverage data structure - #{method_info}", error)
|
62
|
+
when RuntimeError, StandardError
|
63
|
+
# Handle string errors from CovUtil and other runtime errors
|
64
|
+
if error.message.include?('Could not find .resultset.json')
|
65
|
+
# Extract directory info if available
|
66
|
+
dir_info = error.message.match(/under (.+?)(?:;|$)/)&.[](1) || 'project directory'
|
67
|
+
CoverageDataError.new("Coverage data not found in #{dir_info} - please run your tests first", error)
|
68
|
+
elsif error.message.include?('No .resultset.json found in directory')
|
69
|
+
# Extract directory from error message
|
70
|
+
dir_info = error.message.match(/directory: (.+)$/)&.[](1) || 'specified directory'
|
71
|
+
CoverageDataError.new("Coverage data not found in directory: #{dir_info}", error)
|
72
|
+
elsif error.message.include?('Specified resultset not found')
|
73
|
+
# Extract path from error message
|
74
|
+
path_info = error.message.match(/not found: (.+)$/)&.[](1) || 'specified path'
|
75
|
+
ResultsetNotFoundError.new("Resultset file not found: #{path_info}", error)
|
76
|
+
else
|
77
|
+
Error.new("An unexpected error occurred: #{error.message}", error)
|
78
|
+
end
|
79
|
+
else
|
80
|
+
Error.new("An unexpected error occurred: #{error.message}", error)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
def log_error(error, context)
|
87
|
+
return unless log_errors?
|
88
|
+
|
89
|
+
message = build_log_message(error, context)
|
90
|
+
if logger
|
91
|
+
logger.error(message)
|
92
|
+
else
|
93
|
+
CovUtil.log(message)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def build_log_message(error, context)
|
98
|
+
parts = ["Error#{context ? " in #{context}" : ''}: #{error.class}: #{error.message}"]
|
99
|
+
|
100
|
+
if show_stack_traces? && error.backtrace
|
101
|
+
parts << error.backtrace.join("\n")
|
102
|
+
end
|
103
|
+
|
104
|
+
parts.join("\n")
|
105
|
+
end
|
106
|
+
|
107
|
+
def extract_filename(message)
|
108
|
+
# Extract filename from "No such file or directory @ rb_sysopen - filename"
|
109
|
+
match = message.match(/@ \w+ - (.+)$/)
|
110
|
+
match ? match[1] : 'unknown file'
|
111
|
+
end
|
112
|
+
|
113
|
+
def extract_method_info(message)
|
114
|
+
# Extract method info from "undefined method `foo' for #<Object:0x...>"
|
115
|
+
if match = message.match(/undefined method `(.+?)' for (.+)$/)
|
116
|
+
method_name = match[1]
|
117
|
+
object_info = match[2].gsub(/#<.*?>/, 'object')
|
118
|
+
"missing method '#{method_name}' on #{object_info}"
|
119
|
+
else
|
120
|
+
message
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'error_handler'
|
4
|
+
|
5
|
+
module SimpleCovMcp
|
6
|
+
module ErrorHandlerFactory
|
7
|
+
# Error handler for CLI usage
|
8
|
+
# - Logs errors for debugging
|
9
|
+
# - Shows stack traces only when explicitly requested
|
10
|
+
# - Suitable for user-facing command line interface
|
11
|
+
def self.for_cli(error_mode: :on)
|
12
|
+
ErrorHandler.new(error_mode: error_mode)
|
13
|
+
end
|
14
|
+
|
15
|
+
# Error handler for library usage
|
16
|
+
# - No logging by default (avoids side effects in consuming applications)
|
17
|
+
# - No stack traces (libraries should let consumers handle error display)
|
18
|
+
# - Suitable for embedding in other applications
|
19
|
+
def self.for_library(error_mode: :off)
|
20
|
+
ErrorHandler.new(error_mode: error_mode)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Error handler for MCP server usage
|
24
|
+
# - Logs errors for server debugging
|
25
|
+
# - Shows stack traces only when explicitly requested
|
26
|
+
# - Suitable for long-running server processes
|
27
|
+
def self.for_mcp_server(error_mode: :on)
|
28
|
+
ErrorHandler.new(error_mode: error_mode)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,179 @@
|
|
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
|
+
|
17
|
+
protected
|
18
|
+
|
19
|
+
def format_epoch_both(epoch_seconds)
|
20
|
+
return [nil, nil] unless epoch_seconds
|
21
|
+
|
22
|
+
t = Time.at(epoch_seconds.to_i)
|
23
|
+
[t.utc.iso8601, t.getlocal.iso8601]
|
24
|
+
rescue StandardError
|
25
|
+
[epoch_seconds.to_s, epoch_seconds.to_s]
|
26
|
+
end
|
27
|
+
|
28
|
+
def format_time_both(time)
|
29
|
+
return [nil, nil] unless time
|
30
|
+
|
31
|
+
t = time.is_a?(Time) ? time : Time.parse(time.to_s)
|
32
|
+
[t.utc.iso8601, t.getlocal.iso8601]
|
33
|
+
rescue StandardError
|
34
|
+
[time.to_s, time.to_s]
|
35
|
+
end
|
36
|
+
|
37
|
+
def format_delta_seconds(file_mtime, cov_timestamp)
|
38
|
+
return nil unless file_mtime && cov_timestamp
|
39
|
+
|
40
|
+
seconds = file_mtime.to_i - cov_timestamp.to_i
|
41
|
+
sign = seconds >= 0 ? '+' : '-'
|
42
|
+
"#{sign}#{seconds.abs}s"
|
43
|
+
rescue StandardError
|
44
|
+
nil
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Configuration or setup related errors
|
49
|
+
class ConfigurationError < Error
|
50
|
+
def user_friendly_message
|
51
|
+
"Configuration error: #{message}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# File or path related errors
|
56
|
+
class FileError < Error
|
57
|
+
def user_friendly_message
|
58
|
+
"File error: #{message}"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# More specific file errors
|
63
|
+
class FileNotFoundError < FileError; end
|
64
|
+
class FilePermissionError < FileError; end
|
65
|
+
class NotAFileError < FileError; end
|
66
|
+
class ResultsetNotFoundError < FileError; end
|
67
|
+
|
68
|
+
# Coverage data related errors
|
69
|
+
class CoverageDataError < Error
|
70
|
+
def user_friendly_message
|
71
|
+
"Coverage data error: #{message}"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Coverage data is present but appears stale compared to source files
|
76
|
+
class CoverageDataStaleError < CoverageDataError
|
77
|
+
attr_reader :file_path, :file_mtime, :cov_timestamp, :src_len, :cov_len, :resultset_path
|
78
|
+
|
79
|
+
def initialize(message = nil, original_error = nil, file_path: nil, file_mtime: nil,
|
80
|
+
cov_timestamp: nil, src_len: nil, cov_len: nil, resultset_path: nil)
|
81
|
+
super(message, original_error)
|
82
|
+
@file_path = file_path
|
83
|
+
@file_mtime = file_mtime
|
84
|
+
@cov_timestamp = cov_timestamp
|
85
|
+
@src_len = src_len
|
86
|
+
@cov_len = cov_len
|
87
|
+
@resultset_path = resultset_path
|
88
|
+
end
|
89
|
+
|
90
|
+
def user_friendly_message
|
91
|
+
base = "Coverage data stale: #{message || default_message}"
|
92
|
+
base + build_details
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
|
97
|
+
def default_message
|
98
|
+
fp = file_path || 'file'
|
99
|
+
"Coverage data appears stale for #{fp}"
|
100
|
+
end
|
101
|
+
|
102
|
+
def build_details
|
103
|
+
file_utc, file_local = format_time_both(@file_mtime)
|
104
|
+
cov_utc, cov_local = format_epoch_both(@cov_timestamp)
|
105
|
+
delta_str = format_delta_seconds(@file_mtime, @cov_timestamp)
|
106
|
+
|
107
|
+
details = <<~DETAILS
|
108
|
+
|
109
|
+
File - time: #{file_utc || 'not found'} (local #{file_local || 'n/a'}), lines: #{@src_len}
|
110
|
+
Coverage - time: #{cov_utc || 'not found'} (local #{cov_local || 'n/a'}), lines: #{@cov_len}
|
111
|
+
DETAILS
|
112
|
+
|
113
|
+
details += "\nDelta - file is #{delta_str} newer than coverage" if delta_str
|
114
|
+
details += "\nResultset - #{@resultset_path}" if @resultset_path
|
115
|
+
details.chomp
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# Project-level stale coverage (global) — coverage timestamp older than
|
120
|
+
# one or more source files, or new tracked files missing from coverage.
|
121
|
+
class CoverageDataProjectStaleError < CoverageDataError
|
122
|
+
attr_reader :cov_timestamp, :newer_files, :missing_files, :deleted_files, :resultset_path
|
123
|
+
|
124
|
+
def initialize(message = nil, original_error = nil, cov_timestamp: nil, newer_files: [],
|
125
|
+
missing_files: [], deleted_files: [], resultset_path: nil)
|
126
|
+
super(message, original_error)
|
127
|
+
@cov_timestamp = cov_timestamp
|
128
|
+
@newer_files = Array(newer_files)
|
129
|
+
@missing_files = Array(missing_files)
|
130
|
+
@deleted_files = Array(deleted_files)
|
131
|
+
@resultset_path = resultset_path
|
132
|
+
end
|
133
|
+
|
134
|
+
def user_friendly_message
|
135
|
+
base = "Coverage data stale (project): #{message || default_message}"
|
136
|
+
base + build_details
|
137
|
+
end
|
138
|
+
|
139
|
+
private
|
140
|
+
|
141
|
+
def default_message
|
142
|
+
'Coverage data appears stale for project'
|
143
|
+
end
|
144
|
+
|
145
|
+
def build_details
|
146
|
+
cov_utc, cov_local = format_epoch_both(@cov_timestamp)
|
147
|
+
parts = []
|
148
|
+
parts << "\nCoverage - time: #{cov_utc || 'not found'} (local #{cov_local || 'n/a'})"
|
149
|
+
unless @newer_files.empty?
|
150
|
+
parts << "\nNewer files (#{@newer_files.size}):"
|
151
|
+
parts.concat(@newer_files.first(10).map { |f| " - #{f}" })
|
152
|
+
parts << ' ...' if @newer_files.size > 10
|
153
|
+
end
|
154
|
+
unless @missing_files.empty?
|
155
|
+
parts << "\nMissing files (new in project, not in coverage, #{@missing_files.size}):"
|
156
|
+
parts.concat(@missing_files.first(10).map { |f| " - #{f}" })
|
157
|
+
parts << ' ...' if @missing_files.size > 10
|
158
|
+
end
|
159
|
+
unless @deleted_files.empty?
|
160
|
+
parts << "\nCoverage-only files (deleted or moved in project, #{@deleted_files.size}):"
|
161
|
+
parts.concat(@deleted_files.first(10).map { |f| " - #{f}" })
|
162
|
+
parts << ' ...' if @deleted_files.size > 10
|
163
|
+
end
|
164
|
+
parts << "\nResultset - #{@resultset_path}" if @resultset_path
|
165
|
+
parts.join
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
# Command line usage errors
|
170
|
+
class UsageError < Error
|
171
|
+
def self.for_subcommand(usage_fragment)
|
172
|
+
new("Usage: simplecov-mcp #{usage_fragment}")
|
173
|
+
end
|
174
|
+
|
175
|
+
def user_friendly_message
|
176
|
+
message
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SimpleCovMcp
|
4
|
+
module Formatters
|
5
|
+
class SourceFormatter
|
6
|
+
def initialize(color_enabled: true)
|
7
|
+
@color_enabled = color_enabled
|
8
|
+
end
|
9
|
+
|
10
|
+
def format_source_for(model, path, mode: nil, context: 2)
|
11
|
+
raw = fetch_raw(model, path)
|
12
|
+
return '[source not available]' unless raw
|
13
|
+
|
14
|
+
abs = raw['file']
|
15
|
+
lines_cov = raw['lines']
|
16
|
+
src = File.file?(abs) ? File.readlines(abs, chomp: true) : nil
|
17
|
+
return '[source not available]' unless src
|
18
|
+
|
19
|
+
begin
|
20
|
+
rows = build_source_rows(src, lines_cov, mode: mode, context: context)
|
21
|
+
format_source_rows(rows)
|
22
|
+
rescue StandardError
|
23
|
+
# If any unexpected formatting/indexing error occurs, avoid crashing the CLI
|
24
|
+
'[source not available]'
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def build_source_payload(model, path, mode: nil, context: 2)
|
29
|
+
raw = fetch_raw(model, path)
|
30
|
+
return nil unless raw
|
31
|
+
|
32
|
+
abs = raw['file']
|
33
|
+
lines_cov = raw['lines']
|
34
|
+
src = File.file?(abs) ? File.readlines(abs, chomp: true) : nil
|
35
|
+
return nil unless src
|
36
|
+
|
37
|
+
build_source_rows(src, lines_cov, mode: mode, context: context)
|
38
|
+
end
|
39
|
+
|
40
|
+
def build_source_rows(src_lines, cov_lines, mode:, context: 2)
|
41
|
+
# Normalize inputs defensively to avoid type errors in formatting
|
42
|
+
coverage_lines = cov_lines || []
|
43
|
+
context_line_count = context.to_i rescue 2
|
44
|
+
context_line_count = 0 if context_line_count.negative?
|
45
|
+
|
46
|
+
n = src_lines.length
|
47
|
+
include_line = Array.new(n, mode == :full)
|
48
|
+
if mode == :uncovered
|
49
|
+
include_line = mark_uncovered_lines_with_context(coverage_lines, context_line_count, n)
|
50
|
+
end
|
51
|
+
|
52
|
+
build_row_data(src_lines, coverage_lines, include_line)
|
53
|
+
end
|
54
|
+
|
55
|
+
def format_source_rows(rows)
|
56
|
+
marker = ->(covered, hits) do
|
57
|
+
case covered
|
58
|
+
when true then colorize('✓', :green)
|
59
|
+
when false then colorize('·', :red)
|
60
|
+
else colorize(' ', :dim)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
lines = []
|
65
|
+
lines << sprintf('%6s %2s | %s', 'Line', ' ', 'Source')
|
66
|
+
lines << sprintf('%6s %2s-+-%s', '------', '--', '-' * 60)
|
67
|
+
|
68
|
+
rows.each do |r|
|
69
|
+
m = marker.call(r['covered'], r['hits'])
|
70
|
+
lines << sprintf('%6d %2s | %s', r['line'], m, r['code'])
|
71
|
+
end
|
72
|
+
lines.join("\n")
|
73
|
+
end
|
74
|
+
|
75
|
+
def format_detailed_rows(rows)
|
76
|
+
# Simple aligned columns: line, hits, covered
|
77
|
+
out = []
|
78
|
+
out << sprintf('%6s %6s %7s', 'Line', 'Hits', 'Covered')
|
79
|
+
out << sprintf('%6s %6s %7s', '-----', '----', '-------')
|
80
|
+
rows.each do |r|
|
81
|
+
out << sprintf('%6d %6d %7s', r['line'], r['hits'], r['covered'] ? 'yes' : 'no')
|
82
|
+
end
|
83
|
+
out.join("\n")
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
attr_reader :color_enabled
|
89
|
+
|
90
|
+
def fetch_raw(model, path)
|
91
|
+
@raw_cache ||= {}
|
92
|
+
return @raw_cache[path] if @raw_cache.key?(path)
|
93
|
+
|
94
|
+
raw = model.raw_for(path)
|
95
|
+
@raw_cache[path] = raw
|
96
|
+
rescue StandardError
|
97
|
+
nil
|
98
|
+
end
|
99
|
+
|
100
|
+
def mark_uncovered_lines_with_context(coverage_lines, context_line_count, total_lines)
|
101
|
+
include_line = Array.new(total_lines, false)
|
102
|
+
misses = find_uncovered_lines(coverage_lines)
|
103
|
+
|
104
|
+
misses.each do |uncovered_line_index|
|
105
|
+
mark_context_lines(include_line, uncovered_line_index, context_line_count, total_lines)
|
106
|
+
end
|
107
|
+
|
108
|
+
include_line
|
109
|
+
end
|
110
|
+
|
111
|
+
def find_uncovered_lines(coverage_lines)
|
112
|
+
misses = []
|
113
|
+
coverage_lines.each_with_index do |hits, i|
|
114
|
+
misses << i if !hits.nil? && hits.to_i == 0
|
115
|
+
end
|
116
|
+
misses
|
117
|
+
end
|
118
|
+
|
119
|
+
def mark_context_lines(include_line, center_line, context_count, total_lines)
|
120
|
+
start_line = [0, center_line - context_count].max
|
121
|
+
end_line = [total_lines - 1, center_line + context_count].min
|
122
|
+
|
123
|
+
(start_line..end_line).each { |i| include_line[i] = true }
|
124
|
+
end
|
125
|
+
|
126
|
+
def build_row_data(src_lines, coverage_lines, include_line)
|
127
|
+
out = []
|
128
|
+
src_lines.each_with_index do |code, i|
|
129
|
+
next unless include_line[i]
|
130
|
+
|
131
|
+
hits = coverage_lines[i]
|
132
|
+
covered = hits.nil? ? nil : hits.to_i > 0
|
133
|
+
# Use string keys consistently across CLI formatting and JSON payloads
|
134
|
+
out << { 'line' => i + 1, 'code' => code, 'hits' => hits, 'covered' => covered }
|
135
|
+
end
|
136
|
+
out
|
137
|
+
end
|
138
|
+
|
139
|
+
def colorize(text, color)
|
140
|
+
return text unless color_enabled
|
141
|
+
|
142
|
+
codes = { green: 32, red: 31, dim: 2 }
|
143
|
+
code = codes[color] || 0
|
144
|
+
"\e[#{code}m#{text}\e[0m"
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SimpleCovMcp
|
4
|
+
class MCPServer
|
5
|
+
def initialize(context: SimpleCovMcp.context)
|
6
|
+
@context = context
|
7
|
+
end
|
8
|
+
|
9
|
+
def run
|
10
|
+
SimpleCovMcp.with_context(context) do
|
11
|
+
server = ::MCP::Server.new(
|
12
|
+
name: 'simplecov-mcp',
|
13
|
+
version: SimpleCovMcp::VERSION,
|
14
|
+
tools: toolset
|
15
|
+
)
|
16
|
+
::MCP::Server::Transports::StdioTransport.new(server).open
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# Expose the registered tools so embedders can introspect without booting the server.
|
21
|
+
def toolset
|
22
|
+
TOOLSET
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
TOOLSET = [
|
28
|
+
Tools::AllFilesCoverageTool,
|
29
|
+
Tools::CoverageDetailedTool,
|
30
|
+
Tools::CoverageRawTool,
|
31
|
+
Tools::CoverageSummaryTool,
|
32
|
+
Tools::UncoveredLinesTool,
|
33
|
+
Tools::CoverageTableTool,
|
34
|
+
Tools::HelpTool,
|
35
|
+
Tools::VersionTool
|
36
|
+
].freeze
|
37
|
+
|
38
|
+
attr_reader :context
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'constants'
|
4
|
+
|
5
|
+
module SimpleCovMcp
|
6
|
+
# Centralizes the logic for detecting whether to run in CLI or MCP server mode.
|
7
|
+
# This makes the mode detection strategy explicit and testable.
|
8
|
+
class ModeDetector
|
9
|
+
SUBCOMMANDS = %w[list summary raw uncovered detailed version].freeze
|
10
|
+
|
11
|
+
# Reference shared constant to avoid duplication with CoverageCLI
|
12
|
+
OPTIONS_EXPECTING_ARGUMENT = Constants::OPTIONS_EXPECTING_ARGUMENT
|
13
|
+
|
14
|
+
def self.cli_mode?(argv, stdin: STDIN)
|
15
|
+
# 1. Explicit flags that force CLI mode always win
|
16
|
+
cli_options = %w[--force-cli -h --help --version]
|
17
|
+
return true if (argv & cli_options).any?
|
18
|
+
|
19
|
+
# 2. Find the first non-option argument
|
20
|
+
first_non_option = find_first_non_option(argv)
|
21
|
+
|
22
|
+
# 3. If a non-option argument exists, it must be a CLI command (or an error)
|
23
|
+
return true if first_non_option
|
24
|
+
|
25
|
+
# 4. Fallback: If no non-option args, use TTY status to decide
|
26
|
+
stdin.tty?
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.mcp_server_mode?(argv, stdin: STDIN)
|
30
|
+
!cli_mode?(argv, stdin: stdin)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Scans argv and returns the first token that is not an option or a value for an option.
|
34
|
+
def self.find_first_non_option(argv)
|
35
|
+
pending_option = false
|
36
|
+
argv.each do |token|
|
37
|
+
if pending_option
|
38
|
+
pending_option = false
|
39
|
+
next
|
40
|
+
end
|
41
|
+
|
42
|
+
if token.start_with?('-')
|
43
|
+
# Check if the option is one that takes a value and isn't using '=' syntax.
|
44
|
+
pending_option = OPTIONS_EXPECTING_ARGUMENT.include?(token) && !token.include?('=')
|
45
|
+
next
|
46
|
+
end
|
47
|
+
|
48
|
+
# Found the first token that is not an option
|
49
|
+
return token
|
50
|
+
end
|
51
|
+
nil
|
52
|
+
end
|
53
|
+
private_class_method :find_first_non_option
|
54
|
+
end
|
55
|
+
end
|