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
data/exe/simplecov-mcp CHANGED
@@ -2,8 +2,8 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  # Make it safe to run via symlink by resolving the real path
5
- root = File.expand_path("..", File.realpath(__FILE__))
6
- lib = File.join(root, "lib")
5
+ root = File.expand_path('..', File.realpath(__FILE__))
6
+ lib = File.join(root, 'lib')
7
7
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
8
8
 
9
9
  # Fail fast with a helpful message if Ruby is too old for this gem
@@ -11,13 +11,13 @@ begin
11
11
  require 'rubygems'
12
12
  if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.2')
13
13
  $stderr.puts "simplecov-mcp requires Ruby >= 3.2 (current: #{RUBY_VERSION})."
14
- $stderr.puts "Please run with a supported Ruby version (e.g., via rbenv, rvm, asdf)."
14
+ $stderr.puts 'Please run with a supported Ruby version (e.g., via rbenv, rvm, asdf).'
15
15
  exit 1
16
16
  end
17
17
  rescue StandardError
18
18
  # If anything goes wrong, let the app load and potentially fail with a more specific error.
19
19
  end
20
20
 
21
- require 'simple_cov_mcp'
21
+ require 'simplecov_mcp'
22
22
 
23
23
  SimpleCovMcp.run(ARGV)
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCovMcp
4
+ # Encapsulates per-request configuration such as error handling and logging.
5
+ class AppContext
6
+ attr_reader :error_handler, :log_target, :mode
7
+
8
+ def initialize(error_handler:, log_target: nil, mode: :library)
9
+ @error_handler = error_handler
10
+ @log_target = log_target
11
+ @mode = mode
12
+ end
13
+
14
+ def with_error_handler(handler)
15
+ self.class.new(error_handler: handler, log_target: log_target, mode: mode)
16
+ end
17
+
18
+ def with_log_target(target)
19
+ self.class.new(error_handler: error_handler, log_target: target, mode: mode)
20
+ end
21
+
22
+ def mcp_mode? = mode == :mcp_server
23
+ def cli_mode? = mode == :cli
24
+ def library_mode? = mode == :library
25
+ end
26
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp'
4
+ require 'json'
5
+ require_relative 'errors'
6
+ require_relative 'error_handler'
7
+
8
+ module SimpleCovMcp
9
+ class BaseTool < ::MCP::Tool
10
+ INPUT_SCHEMA = {
11
+ type: 'object',
12
+ additionalProperties: false,
13
+ properties: {
14
+ path: {
15
+ type: 'string',
16
+ description: 'Repo-relative or absolute path to the file whose coverage data you need.',
17
+ examples: ['lib/simple_cov_mcp/model.rb']
18
+ },
19
+ root: {
20
+ type: 'string',
21
+ description: 'Project root used to resolve relative paths ' \
22
+ '(defaults to current workspace).',
23
+ default: '.'
24
+ },
25
+ resultset: {
26
+ type: 'string',
27
+ description: 'Path to the SimpleCov .resultset.json file (absolute or relative to root).'
28
+ },
29
+ stale: {
30
+ type: 'string',
31
+ description: 'How to handle missing/outdated coverage data.' \
32
+ "'off' skips checks; 'error' raises.",
33
+ enum: %w[off error],
34
+ default: 'off'
35
+ },
36
+ error_mode: {
37
+ type: 'string',
38
+ description: "Error handling mode: 'off' (silent), 'on' (log errors), 'trace' (verbose).",
39
+ enum: %w[off on trace],
40
+ default: 'on'
41
+ }
42
+ },
43
+ required: ['path']
44
+ }
45
+ def self.input_schema_def = INPUT_SCHEMA
46
+
47
+ # Handle errors consistently across all MCP tools
48
+ # Returns an MCP::Tool::Response with appropriate error message
49
+ def self.handle_mcp_error(error, tool_name, error_mode: :on)
50
+ # Create error handler with the specified mode
51
+ error_handler = ErrorHandlerFactory.for_mcp_server(error_mode: error_mode.to_sym)
52
+
53
+ # Normalize to a SimpleCovMcp::Error so we can handle/log uniformly
54
+ normalized = error.is_a?(SimpleCovMcp::Error) \
55
+ ? error : error_handler.convert_standard_error(error)
56
+ log_mcp_error(normalized, tool_name, error_handler)
57
+ ::MCP::Tool::Response.new([{ type: 'text', text: "Error: #{normalized.user_friendly_message}" }])
58
+ end
59
+
60
+ # Respond with JSON as a resource to avoid clients mutating content types.
61
+ # The resource embeds the JSON string with a clear MIME type.
62
+ def self.respond_json(payload, name: 'data.json', pretty: false)
63
+ json = pretty ? JSON.pretty_generate(payload) : JSON.generate(payload)
64
+ ::MCP::Tool::Response.new([{ 'type' => 'text', 'text' => json }])
65
+ end
66
+
67
+ private
68
+
69
+ def self.log_mcp_error(error, tool_name, error_handler)
70
+ # Use the provided error handler for logging
71
+ error_handler.send(:log_error, error, tool_name)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'cli_config'
5
+ require_relative 'option_parser_builder'
6
+ require_relative 'commands/command_factory'
7
+ require_relative 'option_parsers/error_helper'
8
+ require_relative 'option_parsers/env_options_parser'
9
+ require_relative 'constants'
10
+ require_relative 'presenters/project_coverage_presenter'
11
+
12
+ module SimpleCovMcp
13
+ class CoverageCLI
14
+ SUBCOMMANDS = %w[list summary raw uncovered detailed version].freeze
15
+ HORIZONTAL_RULE = '-' * 79
16
+
17
+ # Reference shared constant to avoid duplication with ModeDetector
18
+ OPTIONS_EXPECTING_ARGUMENT = Constants::OPTIONS_EXPECTING_ARGUMENT
19
+
20
+ attr_reader :config
21
+
22
+ # Initialize CLI for pure CLI usage only.
23
+ # Always runs as CLI, no mode detection needed.
24
+ def initialize(error_handler: nil)
25
+ @config = CLIConfig.new
26
+ @cmd = nil
27
+ @cmd_args = []
28
+ @custom_error_handler = error_handler # Store custom handler if provided
29
+ @error_handler = nil # Will be created after parsing options
30
+ end
31
+
32
+ def run(argv)
33
+ context = nil
34
+ # Prepend environment options to command line arguments
35
+ full_argv = parse_env_opts + argv
36
+ # Pre-scan for error-mode to ensure early errors are logged with correct verbosity
37
+ pre_scan_error_mode(full_argv)
38
+ parse_options!(full_argv)
39
+
40
+ # Create error handler AFTER parsing options to respect user's --error-mode choice
41
+ ensure_error_handler
42
+
43
+ context = SimpleCovMcp.create_context(
44
+ error_handler: @error_handler,
45
+ log_target: config.log_file.nil? ? SimpleCovMcp.context.log_target : config.log_file,
46
+ mode: :cli
47
+ )
48
+
49
+ SimpleCovMcp.with_context(context) do
50
+ # If success predicate specified, run it and exit
51
+ if config.success_predicate
52
+ run_success_predicate
53
+ next
54
+ end
55
+
56
+ if @cmd
57
+ run_subcommand(@cmd, @cmd_args)
58
+ else
59
+ show_default_report(sort_order: config.sort_order)
60
+ end
61
+ end
62
+ rescue OptionParser::ParseError => e
63
+ # Handle any option parsing errors (invalid option/argument) without relying on
64
+ # @error_handler, which is not guaranteed to be initialized yet.
65
+ with_context_if_available(context) { handle_option_parser_error(e, argv: full_argv) }
66
+ rescue SimpleCovMcp::Error => e
67
+ with_context_if_available(context) { handle_user_facing_error(e) }
68
+ end
69
+
70
+ def show_default_report(sort_order: :ascending, output: $stdout)
71
+ model = CoverageModel.new(**config.model_options)
72
+ presenter = Presenters::ProjectCoveragePresenter.new(
73
+ model: model,
74
+ sort_order: sort_order,
75
+ check_stale: (config.stale_mode == :error),
76
+ tracked_globs: config.tracked_globs
77
+ )
78
+
79
+ if config.json
80
+ output.puts JSON.pretty_generate(presenter.relativized_payload)
81
+ return
82
+ end
83
+
84
+ file_summaries = presenter.relative_files
85
+ output.puts model.format_table(
86
+ file_summaries,
87
+ sort_order: sort_order,
88
+ check_stale: (config.stale_mode == :error),
89
+ tracked_globs: nil
90
+ )
91
+ end
92
+
93
+ private
94
+
95
+ def parse_options!(argv)
96
+ require 'optparse'
97
+ extract_subcommand!(argv)
98
+ parser = build_option_parser
99
+ parser.parse!(argv)
100
+ @cmd_args = argv
101
+ end
102
+
103
+ def extract_subcommand!(argv)
104
+ # Environment options (e.g., from SIMPLECOV_MCP_OPTS) may precede the subcommand.
105
+ # Walk the array so we can skip over any option/argument pairs before
106
+ # we decide what the first meaningful token is.
107
+ return if argv.empty?
108
+
109
+ first_unknown = nil
110
+ pending_option = nil
111
+
112
+ argv.each_with_index do |token, index|
113
+ # skip the argument that belongs to the previous option
114
+ if pending_option
115
+ pending_option = nil
116
+ next
117
+ end
118
+
119
+ if token.start_with?('-')
120
+ # CLI options (and --foo=value forms) start with '-'; values beginning with '-' are skipped via pending_option
121
+ # Remember options that expect a following argument so we can skip
122
+ # that value on the next iteration.
123
+ pending_option = expects_argument?(token) && !token.include?('=') ? token : nil
124
+ next
125
+ elsif SUBCOMMANDS.include?(token)
126
+ # Found the real subcommand; pluck it out so option parsing sees the
127
+ # remaining args in their original order.
128
+ @cmd = token
129
+ argv.delete_at(index)
130
+ return
131
+ else
132
+ first_unknown ||= token
133
+ end
134
+ end
135
+
136
+ if first_unknown
137
+ raise UsageError.new("Unknown subcommand: '#{first_unknown}'. Valid subcommands: #{SUBCOMMANDS.join(', ')}")
138
+ end
139
+ end
140
+
141
+ def expects_argument?(option)
142
+ OPTIONS_EXPECTING_ARGUMENT.include?(option)
143
+ end
144
+
145
+ def ensure_error_handler
146
+ @error_handler ||=
147
+ @custom_error_handler || ErrorHandlerFactory.for_cli(error_mode: config.error_mode)
148
+ end
149
+
150
+ def parse_env_opts
151
+ @env_parser ||= OptionParsers::EnvOptionsParser.new
152
+ @env_parser.parse_env_opts
153
+ end
154
+
155
+ def pre_scan_error_mode(argv)
156
+ @env_parser ||= OptionParsers::EnvOptionsParser.new
157
+ config.error_mode = @env_parser.pre_scan_error_mode(argv) || :on
158
+ end
159
+
160
+ def build_option_parser
161
+ builder = OptionParserBuilder.new(config)
162
+ builder.build_option_parser
163
+ end
164
+
165
+ def with_context_if_available(ctx)
166
+ if ctx
167
+ SimpleCovMcp.with_context(ctx) { yield }
168
+ else
169
+ yield
170
+ end
171
+ end
172
+
173
+ def run_subcommand(cmd, args)
174
+ command = Commands::CommandFactory.create(cmd, self)
175
+ command.execute(args)
176
+ rescue SimpleCovMcp::Error => e
177
+ handle_user_facing_error(e)
178
+ rescue => e
179
+ @error_handler.handle_error(e, context: "subcommand '#{cmd}'")
180
+ end
181
+
182
+ def handle_option_parser_error(error, argv: [])
183
+ @error_helper ||= OptionParsers::ErrorHelper.new(SUBCOMMANDS)
184
+ @error_helper.handle_option_parser_error(error, argv: argv)
185
+ end
186
+
187
+ def run_success_predicate
188
+ predicate = load_success_predicate(config.success_predicate)
189
+ model = CoverageModel.new(**config.model_options)
190
+
191
+ result = predicate.call(model)
192
+ exit(result ? 0 : 1)
193
+ rescue => e
194
+ warn "Success predicate error: #{e.message}"
195
+ warn e.backtrace.first(5).join("\n") if config.error_mode == :trace
196
+ exit 2 # Exit code 2 for predicate errors
197
+ end
198
+
199
+ def load_success_predicate(path)
200
+ unless File.exist?(path)
201
+ raise "Success predicate file not found: #{path}"
202
+ end
203
+
204
+ content = File.read(path)
205
+
206
+ # WARNING: The predicate code executes with full Ruby privileges.
207
+ # It has unrestricted access to the file system, network, and system commands.
208
+ # Only use predicate files from trusted sources.
209
+ #
210
+ # We evaluate in a fresh Object context to prevent accidental access to
211
+ # CLI internals, but this provides NO security isolation.
212
+ evaluation_context = Object.new
213
+ predicate = evaluation_context.instance_eval(content, path, 1)
214
+
215
+ unless predicate.respond_to?(:call)
216
+ raise 'Success predicate must be callable (lambda, proc, or object with #call method)'
217
+ end
218
+
219
+ predicate
220
+ rescue SyntaxError => e
221
+ raise "Syntax error in success predicate file: #{e.message}"
222
+ end
223
+
224
+ def handle_user_facing_error(error)
225
+ # Ensure error handler exists (may not be initialized if error occurs during option parsing)
226
+ ensure_error_handler
227
+ # Log the error if error_mode allows it
228
+ @error_handler.handle_error(error, context: 'CLI', reraise: false)
229
+ # Show user-friendly message
230
+ warn error.user_friendly_message
231
+ exit 1
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCovMcp
4
+ # Configuration container for CLI options
5
+ # Uses Struct for simplicity and built-in functionality
6
+ CLIConfig = Struct.new(
7
+ :root,
8
+ :resultset,
9
+ :json,
10
+ :sort_order,
11
+ :source_mode,
12
+ :source_context,
13
+ :color,
14
+ :error_mode,
15
+ :stale_mode,
16
+ :tracked_globs,
17
+ :log_file,
18
+ :success_predicate,
19
+ keyword_init: true
20
+ ) do
21
+ # Set sensible defaults - ALL SYMBOLS FOR ENUMS
22
+ def initialize(
23
+ root: '.',
24
+ resultset: nil,
25
+ json: false,
26
+ sort_order: :ascending,
27
+ source_mode: nil,
28
+ source_context: 2,
29
+ color: STDOUT.tty?,
30
+ error_mode: :on,
31
+ stale_mode: :off,
32
+ tracked_globs: nil,
33
+ log_file: nil,
34
+ success_predicate: nil
35
+ )
36
+ super
37
+ end
38
+
39
+ # Convenience method for CoverageModel initialization
40
+ def model_options
41
+ {
42
+ root: root,
43
+ resultset: resultset,
44
+ staleness: stale_mode,
45
+ tracked_globs: tracked_globs
46
+ }
47
+ end
48
+
49
+ # Convenience method for SourceFormatter initialization
50
+ def formatter_options
51
+ {
52
+ color_enabled: color
53
+ }
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../formatters/source_formatter'
5
+ require_relative '../model'
6
+ require_relative '../errors'
7
+
8
+ module SimpleCovMcp
9
+ module Commands
10
+ class BaseCommand
11
+ def initialize(cli_context)
12
+ @cli = cli_context
13
+ @config = cli_context.config
14
+ @source_formatter = Formatters::SourceFormatter.new(
15
+ **config.formatter_options
16
+ )
17
+ end
18
+
19
+ protected
20
+
21
+ attr_reader :cli, :config, :source_formatter
22
+
23
+ def model
24
+ @model ||= CoverageModel.new(**config.model_options)
25
+ end
26
+
27
+ def handle_with_path(args, name)
28
+ path = args.shift or raise UsageError.for_subcommand("#{name} <path>")
29
+ yield(path)
30
+ rescue Errno::ENOENT => e
31
+ raise FileNotFoundError.new("File not found: #{path}")
32
+ rescue Errno::EACCES => e
33
+ raise FilePermissionError.new("Permission denied: #{path}")
34
+ end
35
+
36
+ def maybe_output_json(obj, model)
37
+ return false unless config.json
38
+
39
+ puts JSON.pretty_generate(model.relativize(obj))
40
+ true
41
+ end
42
+
43
+ def emit_json_with_optional_source(data, model, path)
44
+ return false unless config.json
45
+
46
+ relativized = model.relativize(data)
47
+ if config.source_mode
48
+ payload = relativized.merge('source' => build_source_payload(model, path))
49
+ puts JSON.pretty_generate(payload)
50
+ else
51
+ puts JSON.pretty_generate(relativized)
52
+ end
53
+ true
54
+ end
55
+
56
+ def build_source_payload(model, path)
57
+ source_formatter.build_source_payload(model, path, mode: config.source_mode,
58
+ context: config.source_context)
59
+ end
60
+
61
+ def fetch_raw(model, path)
62
+ @raw_cache ||= {}
63
+ return @raw_cache[path] if @raw_cache.key?(path)
64
+
65
+ raw = model.raw_for(path)
66
+ @raw_cache[path] = raw
67
+ rescue StandardError
68
+ nil
69
+ end
70
+
71
+ def print_source_for(model, path)
72
+ formatted = source_formatter.format_source_for(model, path, mode: config.source_mode,
73
+ context: config.source_context)
74
+ puts formatted
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_command'
4
+ require_relative 'list_command'
5
+ require_relative 'version_command'
6
+ require_relative 'summary_command'
7
+ require_relative 'raw_command'
8
+ require_relative 'uncovered_command'
9
+ require_relative 'detailed_command'
10
+
11
+ module SimpleCovMcp
12
+ module Commands
13
+ class CommandFactory
14
+ COMMAND_MAP = {
15
+ 'list' => ListCommand,
16
+ 'version' => VersionCommand,
17
+ 'summary' => SummaryCommand,
18
+ 'raw' => RawCommand,
19
+ 'uncovered' => UncoveredCommand,
20
+ 'detailed' => DetailedCommand
21
+ }.freeze
22
+
23
+ def self.create(command_name, cli_context)
24
+ command_class = COMMAND_MAP[command_name]
25
+ unless command_class
26
+ raise UsageError.for_subcommand(
27
+ 'list | summary <path> | raw <path> | uncovered <path> | detailed <path> | version'
28
+ )
29
+ end
30
+
31
+ command_class.new(cli_context)
32
+ end
33
+
34
+ def self.available_commands
35
+ COMMAND_MAP.keys
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_command'
4
+ require_relative '../formatters/source_formatter'
5
+ require_relative '../presenters/coverage_detailed_presenter'
6
+
7
+ module SimpleCovMcp
8
+ module Commands
9
+ class DetailedCommand < BaseCommand
10
+ def execute(args)
11
+ handle_with_path(args, 'detailed') do |path|
12
+ presenter = Presenters::CoverageDetailedPresenter.new(model: model, path: path)
13
+ data = presenter.absolute_payload
14
+ break if emit_json_with_optional_source(data, model, path)
15
+
16
+ relative_path = presenter.relative_path
17
+ puts "File: #{relative_path}"
18
+ puts source_formatter.format_detailed_rows(data['lines'])
19
+ print_source_for(model, path) if config.source_mode
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_command'
4
+
5
+ module SimpleCovMcp
6
+ module Commands
7
+ class ListCommand < BaseCommand
8
+ def execute(args)
9
+ cli.send(:show_default_report, sort_order: config.sort_order)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_command'
4
+ require_relative '../presenters/coverage_raw_presenter'
5
+
6
+ module SimpleCovMcp
7
+ module Commands
8
+ class RawCommand < BaseCommand
9
+ def execute(args)
10
+ handle_with_path(args, 'raw') do |path|
11
+ presenter = Presenters::CoverageRawPresenter.new(model: model, path: path)
12
+ data = presenter.absolute_payload
13
+ break if maybe_output_json(data, model)
14
+
15
+ relative_path = presenter.relative_path
16
+ puts "File: #{relative_path}"
17
+ puts data['lines'].inspect
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_command'
4
+ require_relative '../presenters/coverage_summary_presenter'
5
+
6
+ module SimpleCovMcp
7
+ module Commands
8
+ class SummaryCommand < BaseCommand
9
+ def execute(args)
10
+ handle_with_path(args, 'summary') do |path|
11
+ presenter = Presenters::CoverageSummaryPresenter.new(model: model, path: path)
12
+ data = presenter.absolute_payload
13
+ break if emit_json_with_optional_source(data, model, path)
14
+
15
+ relative_path = presenter.relative_path
16
+ summary = data['summary']
17
+ printf "%8.2f%% %6d/%-6d %s\n\n", summary['pct'], summary['covered'], summary['total'],
18
+ relative_path
19
+ print_source_for(model, path) if config.source_mode
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_command'
4
+ require_relative '../presenters/coverage_uncovered_presenter'
5
+
6
+ module SimpleCovMcp
7
+ module Commands
8
+ class UncoveredCommand < BaseCommand
9
+ def execute(args)
10
+ handle_with_path(args, 'uncovered') do |path|
11
+ presenter = Presenters::CoverageUncoveredPresenter.new(model: model, path: path)
12
+ data = presenter.absolute_payload
13
+ break if emit_json_with_optional_source(data, model, path)
14
+
15
+ relative_path = presenter.relative_path
16
+ puts "File: #{relative_path}"
17
+ puts "Uncovered lines: #{data['uncovered'].join(', ')}"
18
+ summary = data['summary']
19
+ printf "Summary: %8.2f%% %6d/%-6d\n\n", summary['pct'], summary['covered'],
20
+ summary['total']
21
+ print_source_for(model, path) if config.source_mode
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'base_command'
5
+
6
+ module SimpleCovMcp
7
+ module Commands
8
+ class VersionCommand < BaseCommand
9
+ def execute(args)
10
+ if config.json
11
+ puts JSON.pretty_generate({ version: SimpleCovMcp::VERSION })
12
+ else
13
+ puts "SimpleCovMcp version #{SimpleCovMcp::VERSION}"
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCovMcp
4
+ # Shared constants used across multiple components to avoid duplication.
5
+ # This ensures consistency between CLI option parsing and mode detection.
6
+ module Constants
7
+ # CLI options that expect an argument value following them.
8
+ # Used by both CoverageCLI and ModeDetector to correctly parse command-line arguments.
9
+ OPTIONS_EXPECTING_ARGUMENT = %w[
10
+ -r --resultset
11
+ -R --root
12
+ -o --sort-order
13
+ -s --source
14
+ -c --source-context
15
+ -S --stale
16
+ -g --tracked-globs
17
+ -l --log-file
18
+ --error-mode
19
+ --success-predicate
20
+ ].freeze
21
+ end
22
+ end