simplecov-mcp 1.0.1 → 2.0.1

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 (164) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +98 -50
  3. data/docs/{ARCHITECTURE.md → dev/ARCHITECTURE.md} +11 -10
  4. data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
  5. data/docs/{DEVELOPMENT.md → dev/DEVELOPMENT.md} +2 -1
  6. data/docs/dev/README.md +10 -0
  7. data/docs/dev/RELEASING.md +146 -0
  8. data/docs/{arch-decisions → dev/arch-decisions}/001-x-arch-decision.md +3 -1
  9. data/docs/{arch-decisions → dev/arch-decisions}/002-x-arch-decision.md +7 -5
  10. data/docs/{arch-decisions → dev/arch-decisions}/003-x-arch-decision.md +2 -0
  11. data/docs/{arch-decisions → dev/arch-decisions}/004-x-arch-decision.md +6 -2
  12. data/docs/{arch-decisions → dev/arch-decisions}/005-x-arch-decision.md +4 -2
  13. data/docs/{arch-decisions → dev/arch-decisions}/README.md +3 -3
  14. data/docs/{presentations → dev/presentations}/simplecov-mcp-presentation.md +28 -22
  15. data/docs/fixtures/demo_project/README.md +9 -0
  16. data/docs/{ADVANCED_USAGE.md → user/ADVANCED_USAGE.md} +129 -319
  17. data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
  18. data/docs/user/CLI_USAGE.md +750 -0
  19. data/docs/{ERROR_HANDLING.md → user/ERROR_HANDLING.md} +12 -12
  20. data/docs/user/EXAMPLES.md +588 -0
  21. data/docs/user/INSTALLATION.md +130 -0
  22. data/docs/{LIBRARY_API.md → user/LIBRARY_API.md} +90 -32
  23. data/docs/{MCP_INTEGRATION.md → user/MCP_INTEGRATION.md} +36 -34
  24. data/docs/user/README.md +14 -0
  25. data/docs/{TROUBLESHOOTING.md → user/TROUBLESHOOTING.md} +21 -100
  26. data/docs/user/V2-BREAKING-CHANGES.md +472 -0
  27. data/exe/simplecov-mcp +1 -1
  28. data/lib/simplecov_mcp/{cli_config.rb → app_config.rb} +12 -12
  29. data/lib/simplecov_mcp/app_context.rb +1 -1
  30. data/lib/simplecov_mcp/base_tool.rb +66 -38
  31. data/lib/simplecov_mcp/cli.rb +67 -123
  32. data/lib/simplecov_mcp/commands/base_command.rb +16 -27
  33. data/lib/simplecov_mcp/commands/command_factory.rb +8 -2
  34. data/lib/simplecov_mcp/commands/detailed_command.rb +16 -2
  35. data/lib/simplecov_mcp/commands/list_command.rb +1 -1
  36. data/lib/simplecov_mcp/commands/raw_command.rb +18 -2
  37. data/lib/simplecov_mcp/commands/summary_command.rb +20 -3
  38. data/lib/simplecov_mcp/commands/totals_command.rb +53 -0
  39. data/lib/simplecov_mcp/commands/uncovered_command.rb +24 -5
  40. data/lib/simplecov_mcp/commands/validate_command.rb +60 -0
  41. data/lib/simplecov_mcp/commands/version_command.rb +19 -4
  42. data/lib/simplecov_mcp/config_parser.rb +32 -0
  43. data/lib/simplecov_mcp/constants.rb +3 -3
  44. data/lib/simplecov_mcp/coverage_reporter.rb +31 -0
  45. data/lib/simplecov_mcp/error_handler.rb +81 -40
  46. data/lib/simplecov_mcp/error_handler_factory.rb +2 -2
  47. data/lib/simplecov_mcp/errors.rb +12 -19
  48. data/lib/simplecov_mcp/formatters/source_formatter.rb +23 -19
  49. data/lib/simplecov_mcp/formatters.rb +51 -0
  50. data/lib/simplecov_mcp/mcp_server.rb +9 -7
  51. data/lib/simplecov_mcp/mode_detector.rb +6 -5
  52. data/lib/simplecov_mcp/model.rb +122 -88
  53. data/lib/simplecov_mcp/option_normalizers.rb +39 -18
  54. data/lib/simplecov_mcp/option_parser_builder.rb +85 -72
  55. data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +3 -5
  56. data/lib/simplecov_mcp/option_parsers/error_helper.rb +18 -17
  57. data/lib/simplecov_mcp/path_relativizer.rb +17 -14
  58. data/lib/simplecov_mcp/predicate_evaluator.rb +72 -0
  59. data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +1 -3
  60. data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +1 -3
  61. data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +1 -3
  62. data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +1 -3
  63. data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +1 -3
  64. data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +1 -3
  65. data/lib/simplecov_mcp/presenters/project_totals_presenter.rb +27 -0
  66. data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +14 -18
  67. data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +7 -9
  68. data/lib/simplecov_mcp/resultset_loader.rb +20 -25
  69. data/lib/simplecov_mcp/staleness_checker.rb +50 -46
  70. data/lib/simplecov_mcp/table_formatter.rb +64 -0
  71. data/lib/simplecov_mcp/tools/all_files_coverage_tool.rb +20 -50
  72. data/lib/simplecov_mcp/tools/coverage_detailed_tool.rb +13 -7
  73. data/lib/simplecov_mcp/tools/coverage_raw_tool.rb +12 -7
  74. data/lib/simplecov_mcp/tools/coverage_summary_tool.rb +13 -8
  75. data/lib/simplecov_mcp/tools/coverage_table_tool.rb +20 -60
  76. data/lib/simplecov_mcp/tools/coverage_totals_tool.rb +44 -0
  77. data/lib/simplecov_mcp/tools/help_tool.rb +38 -66
  78. data/lib/simplecov_mcp/tools/uncovered_lines_tool.rb +13 -8
  79. data/lib/simplecov_mcp/tools/validate_tool.rb +72 -0
  80. data/lib/simplecov_mcp/tools/version_tool.rb +7 -14
  81. data/lib/simplecov_mcp/util.rb +18 -12
  82. data/lib/simplecov_mcp/version.rb +1 -1
  83. data/lib/simplecov_mcp.rb +23 -29
  84. data/spec/all_files_coverage_tool_spec.rb +4 -3
  85. data/spec/{cli_config_spec.rb → app_config_spec.rb} +31 -26
  86. data/spec/base_tool_spec.rb +17 -14
  87. data/spec/cli/show_default_report_spec.rb +2 -2
  88. data/spec/cli_enumerated_options_spec.rb +31 -9
  89. data/spec/cli_error_spec.rb +46 -23
  90. data/spec/cli_format_spec.rb +123 -0
  91. data/spec/cli_json_options_spec.rb +50 -0
  92. data/spec/cli_source_spec.rb +11 -63
  93. data/spec/cli_spec.rb +82 -97
  94. data/spec/cli_usage_spec.rb +15 -15
  95. data/spec/commands/base_command_spec.rb +12 -92
  96. data/spec/commands/command_factory_spec.rb +7 -3
  97. data/spec/commands/detailed_command_spec.rb +10 -24
  98. data/spec/commands/list_command_spec.rb +28 -0
  99. data/spec/commands/raw_command_spec.rb +43 -20
  100. data/spec/commands/summary_command_spec.rb +10 -23
  101. data/spec/commands/totals_command_spec.rb +34 -0
  102. data/spec/commands/uncovered_command_spec.rb +29 -23
  103. data/spec/commands/validate_command_spec.rb +213 -0
  104. data/spec/commands/version_command_spec.rb +38 -0
  105. data/spec/constants_spec.rb +3 -3
  106. data/spec/coverage_reporter_spec.rb +102 -0
  107. data/spec/coverage_table_tool_spec.rb +21 -10
  108. data/spec/coverage_totals_tool_spec.rb +37 -0
  109. data/spec/error_handler_spec.rb +120 -4
  110. data/spec/error_mode_spec.rb +18 -22
  111. data/spec/errors_edge_cases_spec.rb +101 -28
  112. data/spec/errors_stale_spec.rb +34 -0
  113. data/spec/file_based_mcp_tools_spec.rb +6 -6
  114. data/spec/fixtures/project1/lib/bar.rb +2 -0
  115. data/spec/fixtures/project1/lib/foo.rb +2 -0
  116. data/spec/help_tool_spec.rb +2 -18
  117. data/spec/integration_spec.rb +103 -161
  118. data/spec/logging_fallback_spec.rb +3 -3
  119. data/spec/mcp_server_integration_spec.rb +1 -1
  120. data/spec/mcp_server_spec.rb +70 -53
  121. data/spec/mode_detector_spec.rb +46 -41
  122. data/spec/model_error_handling_spec.rb +139 -78
  123. data/spec/model_staleness_spec.rb +13 -13
  124. data/spec/option_normalizers_spec.rb +111 -112
  125. data/spec/option_parsers/env_options_parser_spec.rb +25 -37
  126. data/spec/option_parsers/error_helper_spec.rb +56 -56
  127. data/spec/path_relativizer_spec.rb +15 -0
  128. data/spec/presenters/coverage_detailed_presenter_spec.rb +1 -1
  129. data/spec/presenters/coverage_summary_presenter_spec.rb +1 -1
  130. data/spec/presenters/coverage_uncovered_presenter_spec.rb +1 -1
  131. data/spec/presenters/project_coverage_presenter_spec.rb +9 -8
  132. data/spec/presenters/project_totals_presenter_spec.rb +144 -0
  133. data/spec/resolvers/coverage_line_resolver_spec.rb +261 -36
  134. data/spec/resolvers/resultset_path_resolver_spec.rb +13 -8
  135. data/spec/shared_examples/file_based_mcp_tools.rb +23 -18
  136. data/spec/shared_examples/formatted_command_examples.rb +64 -0
  137. data/spec/simple_cov_mcp_module_spec.rb +24 -3
  138. data/spec/simplecov_mcp/formatters/source_formatter_spec.rb +267 -0
  139. data/spec/simplecov_mcp/formatters_spec.rb +76 -0
  140. data/spec/simplecov_mcp/presenters/base_coverage_presenter_spec.rb +79 -0
  141. data/spec/simplecov_mcp_model_spec.rb +97 -47
  142. data/spec/simplecov_mcp_opts_spec.rb +42 -39
  143. data/spec/spec_helper.rb +27 -92
  144. data/spec/staleness_checker_spec.rb +10 -9
  145. data/spec/staleness_more_spec.rb +4 -4
  146. data/spec/support/cli_helpers.rb +22 -0
  147. data/spec/support/control_flow_helpers.rb +20 -0
  148. data/spec/support/fake_mcp.rb +40 -0
  149. data/spec/support/io_helpers.rb +29 -0
  150. data/spec/support/mcp_helpers.rb +35 -0
  151. data/spec/support/mcp_runner.rb +10 -8
  152. data/spec/support/mocking_helpers.rb +30 -0
  153. data/spec/table_format_spec.rb +70 -0
  154. data/spec/tools/validate_tool_spec.rb +132 -0
  155. data/spec/tools_error_handling_spec.rb +34 -48
  156. data/spec/util_spec.rb +5 -4
  157. data/spec/version_spec.rb +7 -7
  158. data/spec/version_tool_spec.rb +20 -22
  159. metadata +90 -23
  160. data/docs/BRANCH_ONLY_COVERAGE.md +0 -81
  161. data/docs/CLI_USAGE.md +0 -637
  162. data/docs/EXAMPLES.md +0 -430
  163. data/docs/INSTALLATION.md +0 -352
  164. data/spec/cli_success_predicate_spec.rb +0 -141
@@ -1,37 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleCovMcp
4
- # Configuration container for CLI options
4
+ # Configuration container for application options (used by both CLI and MCP modes)
5
5
  # Uses Struct for simplicity and built-in functionality
6
- CLIConfig = Struct.new(
6
+ AppConfig = Struct.new(
7
7
  :root,
8
8
  :resultset,
9
- :json,
9
+ :format,
10
10
  :sort_order,
11
11
  :source_mode,
12
12
  :source_context,
13
13
  :color,
14
14
  :error_mode,
15
- :stale_mode,
15
+ :staleness,
16
16
  :tracked_globs,
17
17
  :log_file,
18
- :success_predicate,
18
+ :show_version,
19
19
  keyword_init: true
20
20
  ) do
21
21
  # Set sensible defaults - ALL SYMBOLS FOR ENUMS
22
22
  def initialize(
23
23
  root: '.',
24
24
  resultset: nil,
25
- json: false,
26
- sort_order: :ascending,
25
+ format: :table,
26
+ sort_order: :descending,
27
27
  source_mode: nil,
28
28
  source_context: 2,
29
- color: STDOUT.tty?,
30
- error_mode: :on,
31
- stale_mode: :off,
29
+ color: $stdout.tty?,
30
+ error_mode: :log,
31
+ staleness: :off,
32
32
  tracked_globs: nil,
33
33
  log_file: nil,
34
- success_predicate: nil
34
+ show_version: false
35
35
  )
36
36
  super
37
37
  end
@@ -41,7 +41,7 @@ module SimpleCovMcp
41
41
  {
42
42
  root: root,
43
43
  resultset: resultset,
44
- staleness: stale_mode,
44
+ staleness: staleness,
45
45
  tracked_globs: tracked_globs
46
46
  }
47
47
  end
@@ -19,7 +19,7 @@ module SimpleCovMcp
19
19
  self.class.new(error_handler: error_handler, log_target: target, mode: mode)
20
20
  end
21
21
 
22
- def mcp_mode? = mode == :mcp_server
22
+ def mcp_mode? = mode == :mcp
23
23
  def cli_mode? = mode == :cli
24
24
  def library_mode? = mode == :library
25
25
  end
@@ -7,46 +7,75 @@ require_relative 'error_handler'
7
7
 
8
8
  module SimpleCovMcp
9
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
- }
10
+ COMMON_PROPERTIES = {
11
+ root: {
12
+ type: 'string',
13
+ description: 'Project root used to resolve relative paths ' \
14
+ '(defaults to current workspace).',
15
+ default: '.'
42
16
  },
17
+ resultset: {
18
+ type: 'string',
19
+ description: 'Path to the SimpleCov .resultset.json file (absolute or relative to root).'
20
+ },
21
+ staleness: {
22
+ type: 'string',
23
+ description: 'How to handle missing/outdated coverage data. ' \
24
+ "'off' skips checks; 'error' raises.",
25
+ enum: [:off, :error],
26
+ default: :off
27
+ },
28
+ error_mode: {
29
+ type: 'string',
30
+ description: "Error handling mode: 'off' (silent), 'log' (log errors), " \
31
+ "'debug' (verbose with backtraces).",
32
+ enum: %w[off log debug],
33
+ default: 'log'
34
+ }
35
+ }.freeze
36
+
37
+ ERROR_MODE_PROPERTY = COMMON_PROPERTIES[:error_mode].freeze
38
+
39
+ TRACKED_GLOBS_PROPERTY = {
40
+ type: 'array',
41
+ description: 'Glob patterns for files that should exist in the coverage report' \
42
+ '(helps flag new files).',
43
+ items: { type: 'string' }
44
+ }.freeze
45
+
46
+ PATH_PROPERTY = {
47
+ type: 'string',
48
+ description: 'Repo-relative or absolute path to the file whose coverage data you need.',
49
+ examples: ['lib/simple_cov_mcp/model.rb']
50
+ }.freeze
51
+
52
+ def self.coverage_schema(additional_properties: {}, required: [])
53
+ {
54
+ type: 'object',
55
+ additionalProperties: false,
56
+ properties: COMMON_PROPERTIES.merge(additional_properties),
57
+ required: required
58
+ }.freeze
59
+ end
60
+
61
+ FILE_INPUT_SCHEMA = coverage_schema(
62
+ additional_properties: { path: PATH_PROPERTY },
43
63
  required: ['path']
44
- }
45
- def self.input_schema_def = INPUT_SCHEMA
64
+ )
65
+ def self.input_schema_def = FILE_INPUT_SCHEMA
66
+
67
+ # Wrap tool execution with consistent error handling.
68
+ # Yields to the block and rescues any error, delegating to handle_mcp_error.
69
+ # This eliminates duplicate rescue blocks across all tools.
70
+ def self.with_error_handling(tool_name, error_mode:)
71
+ yield
72
+ rescue => e
73
+ handle_mcp_error(e, tool_name, error_mode: error_mode)
74
+ end
46
75
 
47
76
  # Handle errors consistently across all MCP tools
48
77
  # Returns an MCP::Tool::Response with appropriate error message
49
- def self.handle_mcp_error(error, tool_name, error_mode: :on)
78
+ def self.handle_mcp_error(error, tool_name, error_mode: :log)
50
79
  # Create error handler with the specified mode
51
80
  error_handler = ErrorHandlerFactory.for_mcp_server(error_mode: error_mode.to_sym)
52
81
 
@@ -54,7 +83,7 @@ module SimpleCovMcp
54
83
  normalized = error.is_a?(SimpleCovMcp::Error) \
55
84
  ? error : error_handler.convert_standard_error(error)
56
85
  log_mcp_error(normalized, tool_name, error_handler)
57
- ::MCP::Tool::Response.new([{ type: 'text', text: "Error: #{normalized.user_friendly_message}" }])
86
+ ::MCP::Tool::Response.new([{ 'type' => 'text', 'text' => "Error: #{normalized.user_friendly_message}" }])
58
87
  end
59
88
 
60
89
  # Respond with JSON as a resource to avoid clients mutating content types.
@@ -64,11 +93,10 @@ module SimpleCovMcp
64
93
  ::MCP::Tool::Response.new([{ 'type' => 'text', 'text' => json }])
65
94
  end
66
95
 
67
- private
68
-
69
96
  def self.log_mcp_error(error, tool_name, error_handler)
70
97
  # Use the provided error handler for logging
71
98
  error_handler.send(:log_error, error, tool_name)
72
99
  end
100
+ private_class_method :log_mcp_error
73
101
  end
74
102
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
- require_relative 'cli_config'
4
+ require_relative 'app_config'
5
5
  require_relative 'option_parser_builder'
6
6
  require_relative 'commands/command_factory'
7
7
  require_relative 'option_parsers/error_helper'
@@ -11,7 +11,7 @@ require_relative 'presenters/project_coverage_presenter'
11
11
 
12
12
  module SimpleCovMcp
13
13
  class CoverageCLI
14
- SUBCOMMANDS = %w[list summary raw uncovered detailed version].freeze
14
+ SUBCOMMANDS = %w[list summary raw uncovered detailed totals validate version].freeze
15
15
  HORIZONTAL_RULE = '-' * 79
16
16
 
17
17
  # Reference shared constant to avoid duplication with ModeDetector
@@ -22,7 +22,7 @@ module SimpleCovMcp
22
22
  # Initialize CLI for pure CLI usage only.
23
23
  # Always runs as CLI, no mode detection needed.
24
24
  def initialize(error_handler: nil)
25
- @config = CLIConfig.new
25
+ @config = AppConfig.new
26
26
  @cmd = nil
27
27
  @cmd_args = []
28
28
  @custom_error_handler = error_handler # Store custom handler if provided
@@ -31,28 +31,19 @@ module SimpleCovMcp
31
31
 
32
32
  def run(argv)
33
33
  context = nil
34
- # Prepend environment options to command line arguments
35
- full_argv = parse_env_opts + argv
34
+ # argv should already include environment options (merged by caller)
36
35
  # 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
36
+ pre_scan_error_mode(argv)
37
+ parse_options!(argv)
38
+ enforce_version_subcommand_if_requested
42
39
 
43
40
  context = SimpleCovMcp.create_context(
44
- error_handler: @error_handler,
41
+ error_handler: error_handler, # construct after options to respect --error-mode
45
42
  log_target: config.log_file.nil? ? SimpleCovMcp.context.log_target : config.log_file,
46
43
  mode: :cli
47
44
  )
48
45
 
49
46
  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
47
  if @cmd
57
48
  run_subcommand(@cmd, @cmd_args)
58
49
  else
@@ -62,22 +53,23 @@ module SimpleCovMcp
62
53
  rescue OptionParser::ParseError => e
63
54
  # Handle any option parsing errors (invalid option/argument) without relying on
64
55
  # @error_handler, which is not guaranteed to be initialized yet.
65
- with_context_if_available(context) { handle_option_parser_error(e, argv: full_argv) }
56
+ with_context_if_available(context) { handle_option_parser_error(e, argv: argv) }
66
57
  rescue SimpleCovMcp::Error => e
67
58
  with_context_if_available(context) { handle_user_facing_error(e) }
68
59
  end
69
60
 
70
- def show_default_report(sort_order: :ascending, output: $stdout)
61
+ def show_default_report(sort_order: :descending, output: $stdout)
71
62
  model = CoverageModel.new(**config.model_options)
72
63
  presenter = Presenters::ProjectCoveragePresenter.new(
73
64
  model: model,
74
65
  sort_order: sort_order,
75
- check_stale: (config.stale_mode == :error),
66
+ check_stale: (config.staleness == :error),
76
67
  tracked_globs: config.tracked_globs
77
68
  )
78
69
 
79
- if config.json
80
- output.puts JSON.pretty_generate(presenter.relativized_payload)
70
+ if config.format != :table
71
+ require_relative 'formatters'
72
+ output.puts Formatters.format(presenter.relativized_payload, config.format)
81
73
  return
82
74
  end
83
75
 
@@ -85,149 +77,101 @@ module SimpleCovMcp
85
77
  output.puts model.format_table(
86
78
  file_summaries,
87
79
  sort_order: sort_order,
88
- check_stale: (config.stale_mode == :error),
80
+ check_stale: (config.staleness == :error),
89
81
  tracked_globs: nil
90
82
  )
91
83
  end
92
84
 
93
- private
94
-
95
- def parse_options!(argv)
85
+ private def parse_options!(argv)
96
86
  require 'optparse'
97
- extract_subcommand!(argv)
98
87
  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
88
 
109
- first_unknown = nil
110
- pending_option = nil
89
+ # order! parses global options (updating config) and removes them from argv.
90
+ # It stops cleanly at the first subcommand (e.g., 'list', 'summary') or unknown option.
91
+ # If it stops at an unknown option, it raises OptionParser::InvalidOption.
92
+ parser.order!(argv)
111
93
 
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
94
+ # The first remaining argument is the subcommand
95
+ @cmd = argv.shift
118
96
 
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
97
+ # Verify it's a valid subcommand if present
98
+ if @cmd && !SUBCOMMANDS.include?(@cmd)
99
+ raise UsageError, "Unknown subcommand: '#{@cmd}'. Valid subcommands: #{SUBCOMMANDS.join(', ')}"
134
100
  end
135
101
 
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)
102
+ # Any remaining arguments belong to the subcommand
103
+ @cmd_args = argv
148
104
  end
149
105
 
150
- def parse_env_opts
151
- @env_parser ||= OptionParsers::EnvOptionsParser.new
152
- @env_parser.parse_env_opts
106
+ private def error_handler
107
+ @error_handler ||= @custom_error_handler ||
108
+ ErrorHandlerFactory.for_cli(error_mode: config.error_mode)
153
109
  end
154
110
 
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
111
+ private def pre_scan_error_mode(argv)
112
+ env_parser = OptionParsers::EnvOptionsParser.new
113
+ config.error_mode = env_parser.pre_scan_error_mode(argv) || :log
158
114
  end
159
115
 
160
- def build_option_parser
116
+ private def build_option_parser
161
117
  builder = OptionParserBuilder.new(config)
162
118
  builder.build_option_parser
163
119
  end
164
120
 
165
- def with_context_if_available(ctx)
121
+ # Converts the -v/--version flags into the version subcommand.
122
+ # When the user passes -v or --version, config.show_version is set to true during option parsing.
123
+ # This method intercepts that flag and redirects execution to the 'version' subcommand,
124
+ # ensuring consistent version display regardless of whether the user runs
125
+ # `simplecov-mcp -v`, `simplecov-mcp --version`, or `simplecov-mcp version`.
126
+ private def enforce_version_subcommand_if_requested
127
+ @cmd = 'version' if config.show_version
128
+ end
129
+
130
+ private def with_context_if_available(ctx, &block)
166
131
  if ctx
167
- SimpleCovMcp.with_context(ctx) { yield }
132
+ SimpleCovMcp.with_context(ctx, &block)
168
133
  else
169
- yield
134
+ block.call
170
135
  end
171
136
  end
172
137
 
173
- def run_subcommand(cmd, args)
138
+ private def run_subcommand(cmd, args)
139
+ # Check if user mistakenly placed global options after the subcommand
140
+ check_for_misplaced_global_options(cmd, args)
141
+
174
142
  command = Commands::CommandFactory.create(cmd, self)
175
143
  command.execute(args)
176
144
  rescue SimpleCovMcp::Error => e
177
145
  handle_user_facing_error(e)
178
146
  rescue => e
179
- @error_handler.handle_error(e, context: "subcommand '#{cmd}'")
147
+ error_handler.handle_error(e, context: "subcommand '#{cmd}'")
180
148
  end
181
149
 
182
- def handle_option_parser_error(error, argv: [])
150
+ private def handle_option_parser_error(error, argv: [])
183
151
  @error_helper ||= OptionParsers::ErrorHelper.new(SUBCOMMANDS)
184
152
  @error_helper.handle_option_parser_error(error, argv: argv)
185
153
  end
186
154
 
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
155
+ private def check_for_misplaced_global_options(cmd, args)
156
+ # Global options that users commonly place after subcommands by mistake
157
+ global_options = %w[-r --resultset -R --root -f --format -o --sort-order -s --source
158
+ -c --context-lines -S --staleness -g --tracked-globs
159
+ -l --log-file --error-mode --color --no-color]
203
160
 
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
161
+ misplaced = args.select { |arg| global_options.include?(arg) }
162
+ return if misplaced.empty?
218
163
 
219
- predicate
220
- rescue SyntaxError => e
221
- raise "Syntax error in success predicate file: #{e.message}"
164
+ option_list = misplaced.join(', ')
165
+ raise UsageError, "Global option(s) must come BEFORE the subcommand.\n" \
166
+ "You used: #{cmd} #{option_list}\n" \
167
+ "Correct: #{option_list} #{cmd}\n\n" \
168
+ "Example: simplecov-mcp --format json #{cmd}"
222
169
  end
223
170
 
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
171
+ private def handle_user_facing_error(error)
172
+ error_handler.handle_error(error, context: 'CLI', reraise: false)
230
173
  warn error.user_friendly_message
174
+ warn error.backtrace.first(5).join("\n") if config.error_mode == :debug && error.backtrace
231
175
  exit 1
232
176
  end
233
177
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require_relative '../formatters'
4
5
  require_relative '../formatters/source_formatter'
5
6
  require_relative '../model'
6
7
  require_relative '../errors'
@@ -16,59 +17,47 @@ module SimpleCovMcp
16
17
  )
17
18
  end
18
19
 
19
- protected
20
-
21
20
  attr_reader :cli, :config, :source_formatter
22
21
 
23
- def model
22
+ protected def model
24
23
  @model ||= CoverageModel.new(**config.model_options)
25
24
  end
26
25
 
27
- def handle_with_path(args, name)
26
+ protected def handle_with_path(args, name)
28
27
  path = args.shift or raise UsageError.for_subcommand("#{name} <path>")
29
28
  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}")
29
+ rescue Errno::ENOENT
30
+ raise FileNotFoundError, "File not found: #{path}"
31
+ rescue Errno::EACCES
32
+ raise FilePermissionError, "Permission denied: #{path}"
34
33
  end
35
34
 
36
- def maybe_output_json(obj, model)
37
- return false unless config.json
35
+ protected def maybe_output_structured_format?(obj, model)
36
+ return false if config.format == :table
38
37
 
39
- puts JSON.pretty_generate(model.relativize(obj))
38
+ puts SimpleCovMcp::Formatters.format(model.relativize(obj), config.format)
40
39
  true
41
40
  end
42
41
 
43
- def emit_json_with_optional_source(data, model, path)
44
- return false unless config.json
42
+ protected def emit_structured_format_with_optional_source?(data, model, path)
43
+ return false if config.format == :table
45
44
 
46
45
  relativized = model.relativize(data)
47
46
  if config.source_mode
48
47
  payload = relativized.merge('source' => build_source_payload(model, path))
49
- puts JSON.pretty_generate(payload)
48
+ puts SimpleCovMcp::Formatters.format(payload, config.format)
50
49
  else
51
- puts JSON.pretty_generate(relativized)
50
+ puts SimpleCovMcp::Formatters.format(relativized, config.format)
52
51
  end
53
52
  true
54
53
  end
55
54
 
56
- def build_source_payload(model, path)
55
+ protected def build_source_payload(model, path)
57
56
  source_formatter.build_source_payload(model, path, mode: config.source_mode,
58
57
  context: config.source_context)
59
58
  end
60
59
 
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)
60
+ protected def print_source_for(model, path)
72
61
  formatted = source_formatter.format_source_for(model, path, mode: config.source_mode,
73
62
  context: config.source_context)
74
63
  puts formatted
@@ -7,6 +7,8 @@ require_relative 'summary_command'
7
7
  require_relative 'raw_command'
8
8
  require_relative 'uncovered_command'
9
9
  require_relative 'detailed_command'
10
+ require_relative 'totals_command'
11
+ require_relative 'validate_command'
10
12
 
11
13
  module SimpleCovMcp
12
14
  module Commands
@@ -17,14 +19,18 @@ module SimpleCovMcp
17
19
  'summary' => SummaryCommand,
18
20
  'raw' => RawCommand,
19
21
  'uncovered' => UncoveredCommand,
20
- 'detailed' => DetailedCommand
22
+ 'detailed' => DetailedCommand,
23
+ 'totals' => TotalsCommand,
24
+ 'total' => TotalsCommand, # Alias for backward compatibility
25
+ 'validate' => ValidateCommand
21
26
  }.freeze
22
27
 
23
28
  def self.create(command_name, cli_context)
24
29
  command_class = COMMAND_MAP[command_name]
25
30
  unless command_class
26
31
  raise UsageError.for_subcommand(
27
- 'list | summary <path> | raw <path> | uncovered <path> | detailed <path> | version'
32
+ 'list | summary <path> | raw <path> | uncovered <path> | detailed <path> ' \
33
+ '| totals | validate <file> | validate -i <code> | version'
28
34
  )
29
35
  end
30
36
 
@@ -3,6 +3,7 @@
3
3
  require_relative 'base_command'
4
4
  require_relative '../formatters/source_formatter'
5
5
  require_relative '../presenters/coverage_detailed_presenter'
6
+ require_relative '../table_formatter'
6
7
 
7
8
  module SimpleCovMcp
8
9
  module Commands
@@ -11,11 +12,24 @@ module SimpleCovMcp
11
12
  handle_with_path(args, 'detailed') do |path|
12
13
  presenter = Presenters::CoverageDetailedPresenter.new(model: model, path: path)
13
14
  data = presenter.absolute_payload
14
- break if emit_json_with_optional_source(data, model, path)
15
+ break if emit_structured_format_with_optional_source?(data, model, path)
15
16
 
16
17
  relative_path = presenter.relative_path
17
18
  puts "File: #{relative_path}"
18
- puts source_formatter.format_detailed_rows(data['lines'])
19
+ puts
20
+
21
+ # Table format with box-drawing
22
+ headers = ['Line', 'Hits', 'Covered']
23
+ rows = data['lines'].map do |r|
24
+ [r['line'].to_s, r['hits'].to_s, r['covered'] ? 'yes' : 'no']
25
+ end
26
+
27
+ puts TableFormatter.format(
28
+ headers: headers,
29
+ rows: rows,
30
+ alignments: [:right, :right, :center]
31
+ )
32
+
19
33
  print_source_for(model, path) if config.source_mode
20
34
  end
21
35
  end
@@ -5,7 +5,7 @@ require_relative 'base_command'
5
5
  module SimpleCovMcp
6
6
  module Commands
7
7
  class ListCommand < BaseCommand
8
- def execute(args)
8
+ def execute(_args)
9
9
  cli.send(:show_default_report, sort_order: config.sort_order)
10
10
  end
11
11
  end