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
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(
|
6
|
-
lib = File.join(root,
|
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
|
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 '
|
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,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
|