simplecov-mcp 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +173 -356
  4. data/docs/ADVANCED_USAGE.md +967 -0
  5. data/docs/ARCHITECTURE.md +79 -0
  6. data/docs/BRANCH_ONLY_COVERAGE.md +81 -0
  7. data/docs/CLI_USAGE.md +637 -0
  8. data/docs/DEVELOPMENT.md +82 -0
  9. data/docs/ERROR_HANDLING.md +93 -0
  10. data/docs/EXAMPLES.md +430 -0
  11. data/docs/INSTALLATION.md +352 -0
  12. data/docs/LIBRARY_API.md +635 -0
  13. data/docs/MCP_INTEGRATION.md +488 -0
  14. data/docs/TROUBLESHOOTING.md +276 -0
  15. data/docs/arch-decisions/001-x-arch-decision.md +93 -0
  16. data/docs/arch-decisions/002-x-arch-decision.md +157 -0
  17. data/docs/arch-decisions/003-x-arch-decision.md +163 -0
  18. data/docs/arch-decisions/004-x-arch-decision.md +199 -0
  19. data/docs/arch-decisions/005-x-arch-decision.md +187 -0
  20. data/docs/arch-decisions/README.md +60 -0
  21. data/docs/presentations/simplecov-mcp-presentation.md +249 -0
  22. data/exe/simplecov-mcp +4 -4
  23. data/lib/simplecov_mcp/app_context.rb +26 -0
  24. data/lib/simplecov_mcp/base_tool.rb +74 -0
  25. data/lib/simplecov_mcp/cli.rb +234 -0
  26. data/lib/simplecov_mcp/cli_config.rb +56 -0
  27. data/lib/simplecov_mcp/commands/base_command.rb +78 -0
  28. data/lib/simplecov_mcp/commands/command_factory.rb +39 -0
  29. data/lib/simplecov_mcp/commands/detailed_command.rb +24 -0
  30. data/lib/simplecov_mcp/commands/list_command.rb +13 -0
  31. data/lib/simplecov_mcp/commands/raw_command.rb +22 -0
  32. data/lib/simplecov_mcp/commands/summary_command.rb +24 -0
  33. data/lib/simplecov_mcp/commands/uncovered_command.rb +26 -0
  34. data/lib/simplecov_mcp/commands/version_command.rb +18 -0
  35. data/lib/simplecov_mcp/constants.rb +22 -0
  36. data/lib/simplecov_mcp/error_handler.rb +124 -0
  37. data/lib/simplecov_mcp/error_handler_factory.rb +31 -0
  38. data/lib/simplecov_mcp/errors.rb +179 -0
  39. data/lib/simplecov_mcp/formatters/source_formatter.rb +148 -0
  40. data/lib/simplecov_mcp/mcp_server.rb +40 -0
  41. data/lib/simplecov_mcp/mode_detector.rb +55 -0
  42. data/lib/simplecov_mcp/model.rb +300 -0
  43. data/lib/simplecov_mcp/option_normalizers.rb +92 -0
  44. data/lib/simplecov_mcp/option_parser_builder.rb +134 -0
  45. data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +50 -0
  46. data/lib/simplecov_mcp/option_parsers/error_helper.rb +109 -0
  47. data/lib/simplecov_mcp/path_relativizer.rb +61 -0
  48. data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +44 -0
  49. data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +16 -0
  50. data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +16 -0
  51. data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +16 -0
  52. data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +16 -0
  53. data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +52 -0
  54. data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +126 -0
  55. data/lib/simplecov_mcp/resolvers/resolver_factory.rb +28 -0
  56. data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +78 -0
  57. data/lib/simplecov_mcp/resultset_loader.rb +136 -0
  58. data/lib/simplecov_mcp/staleness_checker.rb +243 -0
  59. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/all_files_coverage_tool.rb +31 -13
  60. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_detailed_tool.rb +7 -7
  61. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_raw_tool.rb +7 -7
  62. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_summary_tool.rb +7 -7
  63. data/lib/simplecov_mcp/tools/coverage_table_tool.rb +90 -0
  64. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/help_tool.rb +13 -4
  65. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/uncovered_lines_tool.rb +7 -7
  66. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/version_tool.rb +11 -3
  67. data/lib/simplecov_mcp/util.rb +82 -0
  68. data/lib/{simple_cov_mcp → simplecov_mcp}/version.rb +1 -1
  69. data/lib/simplecov_mcp.rb +144 -2
  70. data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
  71. data/spec/TIMESTAMPS.md +48 -0
  72. data/spec/all_files_coverage_tool_spec.rb +29 -25
  73. data/spec/base_tool_spec.rb +11 -10
  74. data/spec/cli/show_default_report_spec.rb +33 -0
  75. data/spec/cli_config_spec.rb +137 -0
  76. data/spec/cli_enumerated_options_spec.rb +68 -0
  77. data/spec/cli_error_spec.rb +105 -47
  78. data/spec/cli_source_spec.rb +82 -23
  79. data/spec/cli_spec.rb +140 -5
  80. data/spec/cli_success_predicate_spec.rb +141 -0
  81. data/spec/cli_table_spec.rb +1 -1
  82. data/spec/cli_usage_spec.rb +10 -26
  83. data/spec/commands/base_command_spec.rb +187 -0
  84. data/spec/commands/command_factory_spec.rb +72 -0
  85. data/spec/commands/detailed_command_spec.rb +48 -0
  86. data/spec/commands/raw_command_spec.rb +46 -0
  87. data/spec/commands/summary_command_spec.rb +47 -0
  88. data/spec/commands/uncovered_command_spec.rb +49 -0
  89. data/spec/constants_spec.rb +61 -0
  90. data/spec/coverage_table_tool_spec.rb +17 -33
  91. data/spec/error_handler_spec.rb +22 -13
  92. data/spec/error_mode_spec.rb +143 -0
  93. data/spec/errors_edge_cases_spec.rb +239 -0
  94. data/spec/errors_stale_spec.rb +2 -2
  95. data/spec/file_based_mcp_tools_spec.rb +99 -0
  96. data/spec/fixtures/project1/lib/bar.rb +0 -1
  97. data/spec/fixtures/project1/lib/foo.rb +0 -1
  98. data/spec/help_tool_spec.rb +11 -17
  99. data/spec/integration_spec.rb +845 -0
  100. data/spec/logging_fallback_spec.rb +128 -0
  101. data/spec/mcp_logging_spec.rb +44 -0
  102. data/spec/mcp_server_integration_spec.rb +23 -0
  103. data/spec/mcp_server_spec.rb +15 -4
  104. data/spec/mode_detector_spec.rb +148 -0
  105. data/spec/model_error_handling_spec.rb +210 -0
  106. data/spec/model_staleness_spec.rb +40 -10
  107. data/spec/option_normalizers_spec.rb +204 -0
  108. data/spec/option_parsers/env_options_parser_spec.rb +233 -0
  109. data/spec/option_parsers/error_helper_spec.rb +222 -0
  110. data/spec/path_relativizer_spec.rb +83 -0
  111. data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
  112. data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
  113. data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
  114. data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
  115. data/spec/presenters/project_coverage_presenter_spec.rb +86 -0
  116. data/spec/resolvers/coverage_line_resolver_spec.rb +57 -0
  117. data/spec/resolvers/resolver_factory_spec.rb +61 -0
  118. data/spec/resolvers/resultset_path_resolver_spec.rb +55 -0
  119. data/spec/resultset_loader_spec.rb +167 -0
  120. data/spec/shared_examples/README.md +115 -0
  121. data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
  122. data/spec/shared_examples/file_based_mcp_tools.rb +174 -0
  123. data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
  124. data/spec/simple_cov_mcp_module_spec.rb +16 -0
  125. data/spec/simplecov_mcp_model_spec.rb +340 -9
  126. data/spec/simplecov_mcp_opts_spec.rb +182 -0
  127. data/spec/spec_helper.rb +147 -4
  128. data/spec/staleness_checker_spec.rb +373 -0
  129. data/spec/staleness_more_spec.rb +16 -13
  130. data/spec/support/mcp_runner.rb +64 -0
  131. data/spec/tools_error_handling_spec.rb +144 -0
  132. data/spec/util_spec.rb +109 -34
  133. data/spec/version_spec.rb +117 -9
  134. data/spec/version_tool_spec.rb +131 -10
  135. metadata +120 -63
  136. data/lib/simple_cov/mcp.rb +0 -9
  137. data/lib/simple_cov_mcp/base_tool.rb +0 -70
  138. data/lib/simple_cov_mcp/cli.rb +0 -390
  139. data/lib/simple_cov_mcp/error_handler.rb +0 -131
  140. data/lib/simple_cov_mcp/error_handler_factory.rb +0 -38
  141. data/lib/simple_cov_mcp/errors.rb +0 -176
  142. data/lib/simple_cov_mcp/mcp_server.rb +0 -30
  143. data/lib/simple_cov_mcp/model.rb +0 -104
  144. data/lib/simple_cov_mcp/staleness_checker.rb +0 -125
  145. data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +0 -61
  146. data/lib/simple_cov_mcp/util.rb +0 -122
  147. data/lib/simple_cov_mcp.rb +0 -102
  148. data/spec/coverage_detailed_tool_spec.rb +0 -36
  149. data/spec/coverage_raw_tool_spec.rb +0 -32
  150. data/spec/coverage_summary_tool_spec.rb +0 -39
  151. data/spec/legacy_shim_spec.rb +0 -13
  152. data/spec/uncovered_lines_tool_spec.rb +0 -33
@@ -0,0 +1,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