simplecov-mcp 1.0.1 → 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 +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 +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 +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
@@ -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,47 +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 ResultsetNotFoundError.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
- # Check if it's a resultset not found error
66
- if e.message.downcase.include?('resultset')
67
- raise ResultsetNotFoundError.new(e.message)
68
- else
69
- raise CoverageDataError.new("Failed to load coverage data: #{e.message}")
70
- end
71
- end
37
+ load_coverage_data(resultset, staleness, tracked_globs)
72
38
  end
73
39
 
74
40
  # Returns { 'file' => <absolute_path>, 'lines' => [hits|nil,...] }
75
41
  def raw_for(path)
76
- file_abs, coverage_lines = resolve(path)
42
+ file_abs, coverage_lines = coverage_data_for(path)
77
43
  { 'file' => file_abs, 'lines' => coverage_lines }
78
44
  end
79
45
 
@@ -81,15 +47,15 @@ module SimpleCovMcp
81
47
  relativizer.relativize(payload)
82
48
  end
83
49
 
84
- # Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, 'pct'=>} }
50
+ # Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, 'percentage'=>} }
85
51
  def summary_for(path)
86
- file_abs, coverage_lines = resolve(path)
52
+ file_abs, coverage_lines = coverage_data_for(path)
87
53
  { 'file' => file_abs, 'summary' => CovUtil.summary(coverage_lines) }
88
54
  end
89
55
 
90
56
  # Returns { 'file' => <absolute_path>, 'uncovered' => [line,...], 'summary' => {...} }
91
57
  def uncovered_for(path)
92
- file_abs, coverage_lines = resolve(path)
58
+ file_abs, coverage_lines = coverage_data_for(path)
93
59
  {
94
60
  'file' => file_abs,
95
61
  'uncovered' => CovUtil.uncovered(coverage_lines),
@@ -99,7 +65,7 @@ module SimpleCovMcp
99
65
 
100
66
  # Returns { 'file' => <absolute_path>, 'lines' => [{'line'=>,'hits'=>,'covered'=>},...], 'summary' => {...} }
101
67
  def detailed_for(path)
102
- file_abs, coverage_lines = resolve(path)
68
+ file_abs, coverage_lines = coverage_data_for(path)
103
69
  {
104
70
  'file' => file_abs,
105
71
  'lines' => CovUtil.detailed(coverage_lines),
@@ -108,8 +74,8 @@ module SimpleCovMcp
108
74
  end
109
75
 
110
76
  # Returns [ { 'file' =>, 'covered' =>, 'total' =>, 'percentage' =>, 'stale' => }, ... ]
111
- def all_files(sort_order: :ascending, check_stale: !@checker.off?, tracked_globs: nil)
112
- 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)
113
79
 
114
80
  rows = @cov.map do |abs_path, _data|
115
81
  begin
@@ -124,7 +90,7 @@ module SimpleCovMcp
124
90
  'file' => abs_path,
125
91
  'covered' => s['covered'],
126
92
  'total' => s['total'],
127
- 'percentage' => s['pct'],
93
+ 'percentage' => s['percentage'],
128
94
  'stale' => stale
129
95
  }
130
96
  end.compact
@@ -132,24 +98,29 @@ module SimpleCovMcp
132
98
  rows = filter_rows_by_globs(rows, tracked_globs)
133
99
 
134
100
  if check_stale
135
- 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)
136
102
  end
137
103
 
138
104
  sort_rows(rows, sort_order: sort_order)
139
105
  end
140
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
+
141
113
  def staleness_for(path)
142
114
  file_abs = File.absolute_path(path, @root)
143
115
  coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
144
116
  @checker.stale_for_file?(file_abs, coverage_lines)
145
- rescue StandardError => e
146
- # Log the error if possible
147
- 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}")
148
119
  false
149
120
  end
150
121
 
151
122
  # Returns formatted table string for all files coverage data
152
- 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?,
153
124
  tracked_globs: nil)
154
125
  rows = prepare_rows(rows, sort_order: sort_order, check_stale: check_stale,
155
126
  tracked_globs: tracked_globs)
@@ -170,9 +141,28 @@ module SimpleCovMcp
170
141
  lines.join("\n")
171
142
  end
172
143
 
173
- 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
174
164
 
175
- def build_staleness_checker(mode:, tracked_globs:)
165
+ private def build_staleness_checker(mode:, tracked_globs:)
176
166
  StalenessChecker.new(
177
167
  root: @root,
178
168
  resultset: @resultset,
@@ -182,7 +172,7 @@ module SimpleCovMcp
182
172
  )
183
173
  end
184
174
 
185
- def prepare_rows(rows, sort_order:, check_stale:, tracked_globs:)
175
+ private def prepare_rows(rows, sort_order:, check_stale:, tracked_globs:)
186
176
  if rows.nil?
187
177
  all_files(sort_order: sort_order, check_stale: check_stale, tracked_globs: tracked_globs)
188
178
  else
@@ -191,7 +181,7 @@ module SimpleCovMcp
191
181
  end
192
182
  end
193
183
 
194
- def sort_rows(rows, sort_order: :ascending)
184
+ private def sort_rows(rows, sort_order: :descending)
195
185
  rows.sort do |a, b|
196
186
  pct_cmp = (sort_order == :descending) \
197
187
  ? (b['percentage'] <=> a['percentage'])
@@ -200,7 +190,7 @@ module SimpleCovMcp
200
190
  end
201
191
  end
202
192
 
203
- def compute_table_widths(rows)
193
+ private def compute_table_widths(rows)
204
194
  max_file_length = rows.map { |f| f['file'].length }.max.to_i
205
195
  file_width = [max_file_length, 'File'.length].max + 2
206
196
  pct_width = 8
@@ -218,7 +208,7 @@ module SimpleCovMcp
218
208
  }
219
209
  end
220
210
 
221
- def border_line(widths, left, middle, right)
211
+ private def border_line(widths, left, middle, right)
222
212
  h_line = ->(col_width) { '─' * (col_width + 2) }
223
213
  left +
224
214
  h_line.call(widths[:file]) +
@@ -229,16 +219,16 @@ module SimpleCovMcp
229
219
  right
230
220
  end
231
221
 
232
- def header_row(widths)
233
- sprintf(
222
+ private def header_row(widths)
223
+ format(
234
224
  "│ %-#{widths[:file]}s │ %#{widths[:pct]}s │ %#{widths[:covered]}s │ %#{widths[:total]}s │ %#{widths[:stale]}s │",
235
225
  'File', ' %', 'Covered', 'Total', 'Stale'.center(widths[:stale])
236
226
  )
237
227
  end
238
228
 
239
- def data_row(file_data, widths)
229
+ private def data_row(file_data, widths)
240
230
  stale_text_str = file_data['stale'] ? file_data['stale'].to_s : ''
241
- sprintf(
231
+ format(
242
232
  "│ %-#{widths[:file]}s │ %#{widths[:pct] - 1}.2f%% │ %#{widths[:covered]}d │ %#{widths[:total]}d │ %#{widths[:stale]}s │",
243
233
  file_data['file'],
244
234
  file_data['percentage'],
@@ -248,58 +238,102 @@ module SimpleCovMcp
248
238
  )
249
239
  end
250
240
 
251
- def summary_counts(rows)
241
+ private def summary_counts(rows)
252
242
  total = rows.length
253
243
  stale_count = rows.count { |f| f['stale'] }
254
244
  ok_count = total - stale_count
255
245
  "Files: total #{total}, ok #{ok_count}, stale #{stale_count}"
256
246
  end
257
247
 
258
- 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)
259
254
  patterns = Array(tracked_globs).compact.map(&:to_s).reject(&:empty?)
260
255
  return rows if patterns.empty?
261
256
 
262
- root_pathname = Pathname.new(@root)
263
- 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
264
260
 
265
- rows.select do |row|
266
- abs_path = row['file']
267
- rel_path = begin
268
- Pathname.new(abs_path).relative_path_from(root_pathname).to_s
269
- rescue ArgumentError
270
- abs_path
271
- 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
272
269
 
273
- patterns.any? do |pattern|
274
- target = Pathname.new(pattern).absolute? ? abs_path : rel_path
275
- File.fnmatch?(pattern, target, flags)
276
- end
277
- 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]:/)
277
+ end
278
+
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) }
278
289
  end
279
290
 
280
- def resolve(path)
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)
281
300
  file_abs = File.absolute_path(path, @root)
282
301
  begin
283
302
  coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
284
- rescue RuntimeError => e
285
- raise FileError.new("No coverage data found for file: #{path}")
303
+ rescue RuntimeError
304
+ raise FileError, "No coverage data found for file: #{path}"
286
305
  end
287
306
  @checker.check_file!(file_abs, coverage_lines) unless @checker.off?
288
307
  if coverage_lines.nil?
289
- raise FileError.new("No coverage data found for file: #{path}")
308
+ raise FileError, "No coverage data found for file: #{path}"
290
309
  end
291
310
 
292
311
  [file_abs, coverage_lines]
293
- rescue Errno::ENOENT => e
294
- raise FileNotFoundError.new("File not found: #{path}")
312
+ rescue Errno::ENOENT
313
+ raise FileNotFoundError, "File not found: #{path}"
295
314
  end
296
315
 
297
- # 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
298
323
 
299
- def check_all_files_staleness!(cov_timestamp, tracked_globs: nil)
300
- # 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
+ }
301
337
  end
302
-
303
- # Detailed stale message construction moved to CoverageDataStaleError
304
338
  end
305
339
  end