simplecov-mcp 1.0.0 → 2.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 (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 +32 -20
  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 -83
  53. data/lib/simplecov_mcp/option_normalizers.rb +39 -18
  54. data/lib/simplecov_mcp/option_parser_builder.rb +82 -65
  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 +114 -170
  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 +141 -82
  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 +99 -49
  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
@@ -19,7 +19,9 @@ module SimpleCovMcp
19
19
  begin
20
20
  rows = build_source_rows(src, lines_cov, mode: mode, context: context)
21
21
  format_source_rows(rows)
22
- rescue StandardError
22
+ rescue ArgumentError
23
+ raise
24
+ rescue
23
25
  # If any unexpected formatting/indexing error occurs, avoid crashing the CLI
24
26
  '[source not available]'
25
27
  end
@@ -40,8 +42,12 @@ module SimpleCovMcp
40
42
  def build_source_rows(src_lines, cov_lines, mode:, context: 2)
41
43
  # Normalize inputs defensively to avoid type errors in formatting
42
44
  coverage_lines = cov_lines || []
43
- context_line_count = context.to_i rescue 2
44
- context_line_count = 0 if context_line_count.negative?
45
+ context_line_count = begin
46
+ context.to_i
47
+ rescue
48
+ 2
49
+ end
50
+ raise ArgumentError, 'Context lines cannot be negative' if context_line_count.negative?
45
51
 
46
52
  n = src_lines.length
47
53
  include_line = Array.new(n, mode == :full)
@@ -53,7 +59,7 @@ module SimpleCovMcp
53
59
  end
54
60
 
55
61
  def format_source_rows(rows)
56
- marker = ->(covered, hits) do
62
+ marker = ->(covered, _hits) do
57
63
  case covered
58
64
  when true then colorize('✓', :green)
59
65
  when false then colorize('·', :red)
@@ -62,12 +68,12 @@ module SimpleCovMcp
62
68
  end
63
69
 
64
70
  lines = []
65
- lines << sprintf('%6s %2s | %s', 'Line', ' ', 'Source')
66
- lines << sprintf('%6s %2s-+-%s', '------', '--', '-' * 60)
71
+ lines << format('%6s %2s | %s', 'Line', ' ', 'Source')
72
+ lines << format('%6s %2s-+-%s', '------', '--', '-' * 60)
67
73
 
68
74
  rows.each do |r|
69
75
  m = marker.call(r['covered'], r['hits'])
70
- lines << sprintf('%6d %2s | %s', r['line'], m, r['code'])
76
+ lines << format('%6d %2s | %s', r['line'], m, r['code'])
71
77
  end
72
78
  lines.join("\n")
73
79
  end
@@ -75,29 +81,27 @@ module SimpleCovMcp
75
81
  def format_detailed_rows(rows)
76
82
  # Simple aligned columns: line, hits, covered
77
83
  out = []
78
- out << sprintf('%6s %6s %7s', 'Line', 'Hits', 'Covered')
79
- out << sprintf('%6s %6s %7s', '-----', '----', '-------')
84
+ out << format('%6s %6s %7s', 'Line', 'Hits', 'Covered')
85
+ out << format('%6s %6s %7s', '-----', '----', '-------')
80
86
  rows.each do |r|
81
- out << sprintf('%6d %6d %7s', r['line'], r['hits'], r['covered'] ? 'yes' : 'no')
87
+ out << format('%6d %6d %5s', r['line'], r['hits'], r['covered'] ? 'yes' : 'no')
82
88
  end
83
89
  out.join("\n")
84
90
  end
85
91
 
86
- private
87
-
88
92
  attr_reader :color_enabled
89
93
 
90
- def fetch_raw(model, path)
94
+ private def fetch_raw(model, path)
91
95
  @raw_cache ||= {}
92
96
  return @raw_cache[path] if @raw_cache.key?(path)
93
97
 
94
98
  raw = model.raw_for(path)
95
99
  @raw_cache[path] = raw
96
- rescue StandardError
100
+ rescue
97
101
  nil
98
102
  end
99
103
 
100
- def mark_uncovered_lines_with_context(coverage_lines, context_line_count, total_lines)
104
+ private def mark_uncovered_lines_with_context(coverage_lines, context_line_count, total_lines)
101
105
  include_line = Array.new(total_lines, false)
102
106
  misses = find_uncovered_lines(coverage_lines)
103
107
 
@@ -108,7 +112,7 @@ module SimpleCovMcp
108
112
  include_line
109
113
  end
110
114
 
111
- def find_uncovered_lines(coverage_lines)
115
+ private def find_uncovered_lines(coverage_lines)
112
116
  misses = []
113
117
  coverage_lines.each_with_index do |hits, i|
114
118
  misses << i if !hits.nil? && hits.to_i == 0
@@ -116,14 +120,14 @@ module SimpleCovMcp
116
120
  misses
117
121
  end
118
122
 
119
- def mark_context_lines(include_line, center_line, context_count, total_lines)
123
+ private def mark_context_lines(include_line, center_line, context_count, total_lines)
120
124
  start_line = [0, center_line - context_count].max
121
125
  end_line = [total_lines - 1, center_line + context_count].min
122
126
 
123
127
  (start_line..end_line).each { |i| include_line[i] = true }
124
128
  end
125
129
 
126
- def build_row_data(src_lines, coverage_lines, include_line)
130
+ private def build_row_data(src_lines, coverage_lines, include_line)
127
131
  out = []
128
132
  src_lines.each_with_index do |code, i|
129
133
  next unless include_line[i]
@@ -136,7 +140,7 @@ module SimpleCovMcp
136
140
  out
137
141
  end
138
142
 
139
- def colorize(text, color)
143
+ private def colorize(text, color)
140
144
  return text unless color_enabled
141
145
 
142
146
  codes = { green: 32, red: 31, dim: 2 }
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module SimpleCovMcp
6
+ module Formatters
7
+ # Maps format symbols to their formatter lambdas
8
+ # Following the rexe pattern for simple, extensible formatting
9
+ FORMATTERS = {
10
+ table: ->(obj) { obj }, # Pass through - table formatting handled elsewhere
11
+ json: lambda(&:to_json),
12
+ pretty_json: ->(obj) { JSON.pretty_generate(obj) },
13
+ yaml: ->(obj) {
14
+ require 'yaml'
15
+ obj.to_yaml
16
+ },
17
+ awesome_print: ->(obj) {
18
+ require 'awesome_print'
19
+ obj.ai
20
+ }
21
+ }.freeze
22
+
23
+ # Maps format symbols to their required libraries
24
+ # Only loaded when the format is actually used
25
+ FORMAT_REQUIRES = {
26
+ yaml: 'yaml',
27
+ awesome_print: 'awesome_print'
28
+ }.freeze
29
+
30
+ # Returns the formatter lambda for the given format
31
+ def self.formatter_for(format)
32
+ FORMATTERS[format] or raise ArgumentError, "Unknown format: #{format}"
33
+ end
34
+
35
+ # Ensures required libraries are loaded for the given format
36
+ def self.ensure_requirements_for(format)
37
+ requirement = FORMAT_REQUIRES[format]
38
+ require requirement if requirement
39
+ end
40
+
41
+ # Formats an object using the specified format
42
+ def self.format(obj, format)
43
+ ensure_requirements_for(format)
44
+ formatter_for(format).call(obj)
45
+ rescue LoadError => e
46
+ gem_name = e.message[/-- (\S+)/, 1] || 'required gem'
47
+ raise LoadError, "The #{format} format requires the '#{gem_name}' gem. " \
48
+ "Install it with: gem install #{gem_name}"
49
+ end
50
+ end
51
+ end
@@ -17,24 +17,26 @@ module SimpleCovMcp
17
17
  end
18
18
  end
19
19
 
20
- # Expose the registered tools so embedders can introspect without booting the server.
21
- def toolset
22
- TOOLSET
23
- end
24
-
25
- private
26
-
27
20
  TOOLSET = [
28
21
  Tools::AllFilesCoverageTool,
29
22
  Tools::CoverageDetailedTool,
30
23
  Tools::CoverageRawTool,
31
24
  Tools::CoverageSummaryTool,
25
+ Tools::CoverageTotalsTool,
32
26
  Tools::UncoveredLinesTool,
33
27
  Tools::CoverageTableTool,
28
+ Tools::ValidateTool,
34
29
  Tools::HelpTool,
35
30
  Tools::VersionTool
36
31
  ].freeze
37
32
 
33
+ # Expose the registered tools so embedders can introspect without booting the server.
34
+ def toolset
35
+ TOOLSET
36
+ end
37
+
38
+ private
39
+
38
40
  attr_reader :context
39
41
  end
40
42
  end
@@ -6,15 +6,16 @@ module SimpleCovMcp
6
6
  # Centralizes the logic for detecting whether to run in CLI or MCP server mode.
7
7
  # This makes the mode detection strategy explicit and testable.
8
8
  class ModeDetector
9
- SUBCOMMANDS = %w[list summary raw uncovered detailed version].freeze
9
+ SUBCOMMANDS = %w[list summary raw uncovered detailed totals validate version].freeze
10
10
 
11
11
  # Reference shared constant to avoid duplication with CoverageCLI
12
12
  OPTIONS_EXPECTING_ARGUMENT = Constants::OPTIONS_EXPECTING_ARGUMENT
13
13
 
14
- def self.cli_mode?(argv, stdin: STDIN)
14
+ def self.cli_mode?(argv, stdin: $stdin)
15
15
  # 1. Explicit flags that force CLI mode always win
16
- cli_options = %w[--force-cli -h --help --version]
17
- return true if (argv & cli_options).any?
16
+ cli_options = %w[--force-cli -h --help --version -v]
17
+ return true if argv.intersect?(cli_options)
18
+
18
19
 
19
20
  # 2. Find the first non-option argument
20
21
  first_non_option = find_first_non_option(argv)
@@ -26,7 +27,7 @@ module SimpleCovMcp
26
27
  stdin.tty?
27
28
  end
28
29
 
29
- def self.mcp_server_mode?(argv, stdin: STDIN)
30
+ def self.mcp_server_mode?(argv, stdin: $stdin)
30
31
  !cli_mode?(argv, stdin: stdin)
31
32
  end
32
33
 
@@ -5,6 +5,7 @@ require 'json'
5
5
 
6
6
  require_relative 'util'
7
7
  require_relative 'errors'
8
+ require_relative 'error_handler'
8
9
  require_relative 'staleness_checker'
9
10
  require_relative 'path_relativizer'
10
11
  require_relative 'resultset_loader'
@@ -21,10 +22,10 @@ module SimpleCovMcp
21
22
  # Params:
22
23
  # - root: project root directory (default '.')
23
24
  # - resultset: path or directory to .resultset.json
24
- # - staleness: 'off' or 'error' (default 'off'). When 'error', raises
25
+ # - staleness: :off or :error (default :off). When :error, raises
25
26
  # stale errors if sources are newer than coverage or line counts mismatch.
26
27
  # - tracked_globs: only used for all_files project-level staleness.
27
- def initialize(root: '.', resultset: nil, staleness: 'off', tracked_globs: nil)
28
+ def initialize(root: '.', resultset: nil, staleness: :off, tracked_globs: nil)
28
29
  @root = File.absolute_path(root || '.')
29
30
  @resultset = resultset
30
31
  @relativizer = PathRelativizer.new(
@@ -33,42 +34,12 @@ module SimpleCovMcp
33
34
  array_keys: RELATIVIZER_ARRAY_KEYS
34
35
  )
35
36
 
36
- begin
37
- rs = CovUtil.find_resultset(@root, resultset: resultset)
38
- loaded = ResultsetLoader.load(resultset_path: rs)
39
- coverage_map = loaded.coverage_map or raise CoverageDataError.new("No 'coverage' key found in resultset file: #{rs}")
40
-
41
- @cov = coverage_map.transform_keys { |k| File.absolute_path(k, @root) }
42
- @cov_timestamp = loaded.timestamp
43
-
44
- @checker = StalenessChecker.new(
45
- root: @root,
46
- resultset: @resultset,
47
- mode: staleness,
48
- tracked_globs: tracked_globs,
49
- timestamp: @cov_timestamp
50
- )
51
- rescue Errno::ENOENT => e
52
- raise FileError.new("Coverage data not found at #{resultset || @root}")
53
- rescue JSON::ParserError => e
54
- raise CoverageDataError.new("Invalid coverage data format: #{e.message}")
55
- rescue Errno::EACCES => e
56
- raise FilePermissionError.new("Permission denied reading coverage data: #{e.message}")
57
- rescue TypeError, NoMethodError => e
58
- # These typically indicate the resultset has an unexpected structure
59
- raise CoverageDataError.new("Invalid coverage data structure: #{e.message}")
60
- rescue ArgumentError => e
61
- # ArgumentError can occur from File.absolute_path or other path operations
62
- raise CoverageDataError.new("Invalid path in coverage data: #{e.message}")
63
- rescue RuntimeError => e
64
- # RuntimeError from find_resultset or other operations
65
- raise CoverageDataError.new("Failed to load coverage data: #{e.message}")
66
- end
37
+ load_coverage_data(resultset, staleness, tracked_globs)
67
38
  end
68
39
 
69
40
  # Returns { 'file' => <absolute_path>, 'lines' => [hits|nil,...] }
70
41
  def raw_for(path)
71
- file_abs, coverage_lines = resolve(path)
42
+ file_abs, coverage_lines = coverage_data_for(path)
72
43
  { 'file' => file_abs, 'lines' => coverage_lines }
73
44
  end
74
45
 
@@ -76,15 +47,15 @@ module SimpleCovMcp
76
47
  relativizer.relativize(payload)
77
48
  end
78
49
 
79
- # Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, 'pct'=>} }
50
+ # Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, 'percentage'=>} }
80
51
  def summary_for(path)
81
- file_abs, coverage_lines = resolve(path)
52
+ file_abs, coverage_lines = coverage_data_for(path)
82
53
  { 'file' => file_abs, 'summary' => CovUtil.summary(coverage_lines) }
83
54
  end
84
55
 
85
56
  # Returns { 'file' => <absolute_path>, 'uncovered' => [line,...], 'summary' => {...} }
86
57
  def uncovered_for(path)
87
- file_abs, coverage_lines = resolve(path)
58
+ file_abs, coverage_lines = coverage_data_for(path)
88
59
  {
89
60
  'file' => file_abs,
90
61
  'uncovered' => CovUtil.uncovered(coverage_lines),
@@ -94,7 +65,7 @@ module SimpleCovMcp
94
65
 
95
66
  # Returns { 'file' => <absolute_path>, 'lines' => [{'line'=>,'hits'=>,'covered'=>},...], 'summary' => {...} }
96
67
  def detailed_for(path)
97
- file_abs, coverage_lines = resolve(path)
68
+ file_abs, coverage_lines = coverage_data_for(path)
98
69
  {
99
70
  'file' => file_abs,
100
71
  'lines' => CovUtil.detailed(coverage_lines),
@@ -103,8 +74,8 @@ module SimpleCovMcp
103
74
  end
104
75
 
105
76
  # Returns [ { 'file' =>, 'covered' =>, 'total' =>, 'percentage' =>, 'stale' => }, ... ]
106
- def all_files(sort_order: :ascending, check_stale: !@checker.off?, tracked_globs: nil)
107
- stale_checker = build_staleness_checker(mode: 'off', tracked_globs: tracked_globs)
77
+ def all_files(sort_order: :descending, check_stale: !@checker.off?, tracked_globs: nil)
78
+ stale_checker = build_staleness_checker(mode: :off, tracked_globs: tracked_globs)
108
79
 
109
80
  rows = @cov.map do |abs_path, _data|
110
81
  begin
@@ -119,7 +90,7 @@ module SimpleCovMcp
119
90
  'file' => abs_path,
120
91
  'covered' => s['covered'],
121
92
  'total' => s['total'],
122
- 'percentage' => s['pct'],
93
+ 'percentage' => s['percentage'],
123
94
  'stale' => stale
124
95
  }
125
96
  end.compact
@@ -127,24 +98,29 @@ module SimpleCovMcp
127
98
  rows = filter_rows_by_globs(rows, tracked_globs)
128
99
 
129
100
  if check_stale
130
- build_staleness_checker(mode: 'error', tracked_globs: tracked_globs).check_project!(@cov)
101
+ build_staleness_checker(mode: :error, tracked_globs: tracked_globs).check_project!(@cov)
131
102
  end
132
103
 
133
104
  sort_rows(rows, sort_order: sort_order)
134
105
  end
135
106
 
107
+ def project_totals(tracked_globs: nil, check_stale: !@checker.off?)
108
+ rows = all_files(sort_order: :ascending, check_stale: check_stale,
109
+ tracked_globs: tracked_globs)
110
+ totals_from_rows(rows)
111
+ end
112
+
136
113
  def staleness_for(path)
137
114
  file_abs = File.absolute_path(path, @root)
138
115
  coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
139
116
  @checker.stale_for_file?(file_abs, coverage_lines)
140
- rescue StandardError => e
141
- # Log the error if possible
142
- CovUtil.log("Failed to check staleness for #{path}: #{e.message}") rescue nil
117
+ rescue => e
118
+ CovUtil.safe_log("Failed to check staleness for #{path}: #{e.message}")
143
119
  false
144
120
  end
145
121
 
146
122
  # Returns formatted table string for all files coverage data
147
- def format_table(rows = nil, sort_order: :ascending, check_stale: !@checker.off?,
123
+ def format_table(rows = nil, sort_order: :descending, check_stale: !@checker.off?,
148
124
  tracked_globs: nil)
149
125
  rows = prepare_rows(rows, sort_order: sort_order, check_stale: check_stale,
150
126
  tracked_globs: tracked_globs)
@@ -165,9 +141,28 @@ module SimpleCovMcp
165
141
  lines.join("\n")
166
142
  end
167
143
 
168
- private
144
+ private def load_coverage_data(resultset, staleness, tracked_globs)
145
+ rs = CovUtil.find_resultset(@root, resultset: resultset)
146
+ loaded = ResultsetLoader.load(resultset_path: rs)
147
+ coverage_map = loaded.coverage_map or raise(CoverageDataError, "No 'coverage' key found in resultset file: #{rs}")
148
+
149
+ @cov = coverage_map.transform_keys { |k| File.absolute_path(k, @root) }
150
+ @cov_timestamp = loaded.timestamp
151
+
152
+ @checker = StalenessChecker.new(
153
+ root: @root,
154
+ resultset: @resultset,
155
+ mode: staleness,
156
+ tracked_globs: tracked_globs,
157
+ timestamp: @cov_timestamp
158
+ )
159
+ rescue SimpleCovMcp::Error
160
+ raise # Re-raise our own errors as-is
161
+ rescue => e
162
+ raise ErrorHandler.new.convert_standard_error(e, context: :coverage_loading)
163
+ end
169
164
 
170
- def build_staleness_checker(mode:, tracked_globs:)
165
+ private def build_staleness_checker(mode:, tracked_globs:)
171
166
  StalenessChecker.new(
172
167
  root: @root,
173
168
  resultset: @resultset,
@@ -177,7 +172,7 @@ module SimpleCovMcp
177
172
  )
178
173
  end
179
174
 
180
- def prepare_rows(rows, sort_order:, check_stale:, tracked_globs:)
175
+ private def prepare_rows(rows, sort_order:, check_stale:, tracked_globs:)
181
176
  if rows.nil?
182
177
  all_files(sort_order: sort_order, check_stale: check_stale, tracked_globs: tracked_globs)
183
178
  else
@@ -186,7 +181,7 @@ module SimpleCovMcp
186
181
  end
187
182
  end
188
183
 
189
- def sort_rows(rows, sort_order: :ascending)
184
+ private def sort_rows(rows, sort_order: :descending)
190
185
  rows.sort do |a, b|
191
186
  pct_cmp = (sort_order == :descending) \
192
187
  ? (b['percentage'] <=> a['percentage'])
@@ -195,7 +190,7 @@ module SimpleCovMcp
195
190
  end
196
191
  end
197
192
 
198
- def compute_table_widths(rows)
193
+ private def compute_table_widths(rows)
199
194
  max_file_length = rows.map { |f| f['file'].length }.max.to_i
200
195
  file_width = [max_file_length, 'File'.length].max + 2
201
196
  pct_width = 8
@@ -213,7 +208,7 @@ module SimpleCovMcp
213
208
  }
214
209
  end
215
210
 
216
- def border_line(widths, left, middle, right)
211
+ private def border_line(widths, left, middle, right)
217
212
  h_line = ->(col_width) { '─' * (col_width + 2) }
218
213
  left +
219
214
  h_line.call(widths[:file]) +
@@ -224,16 +219,16 @@ module SimpleCovMcp
224
219
  right
225
220
  end
226
221
 
227
- def header_row(widths)
228
- sprintf(
222
+ private def header_row(widths)
223
+ format(
229
224
  "│ %-#{widths[:file]}s │ %#{widths[:pct]}s │ %#{widths[:covered]}s │ %#{widths[:total]}s │ %#{widths[:stale]}s │",
230
225
  'File', ' %', 'Covered', 'Total', 'Stale'.center(widths[:stale])
231
226
  )
232
227
  end
233
228
 
234
- def data_row(file_data, widths)
229
+ private def data_row(file_data, widths)
235
230
  stale_text_str = file_data['stale'] ? file_data['stale'].to_s : ''
236
- sprintf(
231
+ format(
237
232
  "│ %-#{widths[:file]}s │ %#{widths[:pct] - 1}.2f%% │ %#{widths[:covered]}d │ %#{widths[:total]}d │ %#{widths[:stale]}s │",
238
233
  file_data['file'],
239
234
  file_data['percentage'],
@@ -243,58 +238,102 @@ module SimpleCovMcp
243
238
  )
244
239
  end
245
240
 
246
- def summary_counts(rows)
241
+ private def summary_counts(rows)
247
242
  total = rows.length
248
243
  stale_count = rows.count { |f| f['stale'] }
249
244
  ok_count = total - stale_count
250
245
  "Files: total #{total}, ok #{ok_count}, stale #{stale_count}"
251
246
  end
252
247
 
253
- def filter_rows_by_globs(rows, tracked_globs)
248
+ # Filters coverage rows to only include files matching the given glob patterns.
249
+ #
250
+ # @param rows [Array<Hash>] coverage rows with 'file' keys containing absolute paths
251
+ # @param tracked_globs [Array<String>, String, nil] glob patterns to match against
252
+ # @return [Array<Hash>] rows whose files match at least one pattern (or all rows if no patterns)
253
+ private def filter_rows_by_globs(rows, tracked_globs)
254
254
  patterns = Array(tracked_globs).compact.map(&:to_s).reject(&:empty?)
255
255
  return rows if patterns.empty?
256
256
 
257
- root_pathname = Pathname.new(@root)
258
- flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
257
+ absolute_patterns = patterns.map { |p| absolutize_pattern(p) }
258
+ rows.select { |row| matches_any_pattern?(row['file'], absolute_patterns) }
259
+ end
259
260
 
260
- rows.select do |row|
261
- abs_path = row['file']
262
- rel_path = begin
263
- Pathname.new(abs_path).relative_path_from(root_pathname).to_s
264
- rescue ArgumentError
265
- abs_path
266
- end
261
+ # Converts a relative pattern to absolute by joining with root.
262
+ # Absolute patterns are returned unchanged.
263
+ #
264
+ # @param pattern [String] glob pattern (e.g., "lib/**/*.rb")
265
+ # @return [String] absolute pattern
266
+ private def absolutize_pattern(pattern)
267
+ absolute_pattern?(pattern) ? pattern : File.join(@root, pattern)
268
+ end
267
269
 
268
- patterns.any? do |pattern|
269
- target = Pathname.new(pattern).absolute? ? abs_path : rel_path
270
- File.fnmatch?(pattern, target, flags)
271
- end
272
- end
270
+ # Checks if a pattern is absolute, handling both Unix and Windows-style paths.
271
+ # On Unix, Pathname won't recognize "C:/" as absolute, so we check explicitly.
272
+ #
273
+ # @param pattern [String] glob pattern
274
+ # @return [Boolean] true if pattern is absolute
275
+ private def absolute_pattern?(pattern)
276
+ Pathname.new(pattern).absolute? || pattern.match?(/\A[A-Za-z]:/)
273
277
  end
274
278
 
275
- def resolve(path)
279
+ # Tests if a file path matches any of the given absolute glob patterns.
280
+ # Uses File.fnmatch? for pure string matching without filesystem access,
281
+ # which is faster and works for paths that may no longer exist on disk.
282
+ #
283
+ # @param abs_path [String] absolute file path to test
284
+ # @param patterns [Array<String>] absolute glob patterns
285
+ # @return [Boolean] true if the path matches at least one pattern
286
+ private def matches_any_pattern?(abs_path, patterns)
287
+ flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
288
+ patterns.any? { |pattern| File.fnmatch?(pattern, abs_path, flags) }
289
+ end
290
+
291
+ # Retrieves coverage data for a file path.
292
+ # Converts the path to absolute form and performs staleness checking if enabled.
293
+ #
294
+ # @param path [String] relative or absolute file path
295
+ # @return [Array(String, Array)] tuple of [absolute_path, coverage_lines]
296
+ # @raise [FileError] if no coverage data exists for the file
297
+ # @raise [FileNotFoundError] if the file does not exist
298
+ # @raise [CoverageDataStaleError] if staleness checking is enabled and data is stale
299
+ private def coverage_data_for(path)
276
300
  file_abs = File.absolute_path(path, @root)
277
301
  begin
278
302
  coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
279
- rescue RuntimeError => e
280
- raise FileError.new("No coverage data found for file: #{path}")
303
+ rescue RuntimeError
304
+ raise FileError, "No coverage data found for file: #{path}"
281
305
  end
282
306
  @checker.check_file!(file_abs, coverage_lines) unless @checker.off?
283
307
  if coverage_lines.nil?
284
- raise FileError.new("No coverage data found for file: #{path}")
308
+ raise FileError, "No coverage data found for file: #{path}"
285
309
  end
286
310
 
287
311
  [file_abs, coverage_lines]
288
- rescue Errno::ENOENT => e
289
- raise FileNotFoundError.new("File not found: #{path}")
312
+ rescue Errno::ENOENT
313
+ raise FileNotFoundError, "File not found: #{path}"
290
314
  end
291
315
 
292
- # staleness handled by StalenessChecker
316
+ private def totals_from_rows(rows)
317
+ covered = rows.sum { |row| row['covered'].to_i }
318
+ total = rows.sum { |row| row['total'].to_i }
319
+ uncovered = total - covered
320
+ percentage = total.zero? ? 100.0 : ((covered.to_f * 100.0 / total) * 100).round / 100.0
321
+ stale_count = rows.count { |row| row['stale'] }
322
+ files_total = rows.length
293
323
 
294
- def check_all_files_staleness!(cov_timestamp, tracked_globs: nil)
295
- # handled by StalenessChecker
324
+ {
325
+ 'lines' => {
326
+ 'covered' => covered,
327
+ 'uncovered' => uncovered,
328
+ 'total' => total
329
+ },
330
+ 'percentage' => percentage,
331
+ 'files' => {
332
+ 'total' => files_total,
333
+ 'ok' => files_total - stale_count,
334
+ 'stale' => stale_count
335
+ }
336
+ }
296
337
  end
297
-
298
- # Detailed stale message construction moved to CoverageDataStaleError
299
338
  end
300
339
  end