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
@@ -9,45 +9,41 @@ module SimpleCovMcp
9
9
 
10
10
  def lookup_lines(file_abs)
11
11
  # First try exact match
12
- if direct_match = find_direct_match(file_abs)
13
- return direct_match
14
- end
12
+ direct_match = find_direct_match(file_abs)
13
+ return direct_match if direct_match
15
14
 
16
15
  # Then try without current working directory prefix
17
- if stripped_match = find_stripped_match(file_abs)
18
- return stripped_match
19
- end
16
+ stripped_match = find_stripped_match(file_abs)
17
+ return stripped_match if stripped_match
20
18
 
21
19
  raise_not_found_error(file_abs)
22
20
  end
23
21
 
24
- private
25
-
26
22
  attr_reader :cov_data
27
23
 
28
- def find_direct_match(file_abs)
24
+ private def find_direct_match(file_abs)
29
25
  entry = cov_data[file_abs]
30
26
  lines_from_entry(entry)
31
27
  end
32
28
 
33
- def find_stripped_match(file_abs)
29
+ private def find_stripped_match(file_abs)
34
30
  return unless file_abs.start_with?(cwd_with_slash)
35
31
 
36
- relative_path = file_abs[(cwd.length + 1)..-1]
32
+ relative_path = file_abs[(cwd.length + 1)..]
37
33
  entry = cov_data[relative_path]
38
34
  lines_from_entry(entry)
39
35
  end
40
36
 
41
- def cwd
37
+ private def cwd
42
38
  @cwd ||= Dir.pwd
43
39
  end
44
40
 
45
- def cwd_with_slash
41
+ private def cwd_with_slash
46
42
  @cwd_with_slash ||= "#{cwd}/"
47
43
  end
48
44
 
49
- def raise_not_found_error(file_abs)
50
- raise FileError.new("No coverage entry found for #{file_abs}")
45
+ private def raise_not_found_error(file_abs)
46
+ raise FileError, "No coverage entry found for #{file_abs}"
51
47
  end
52
48
 
53
49
  # Entry may store exact line coverage, branch-only coverage, or neither.
@@ -56,7 +52,7 @@ module SimpleCovMcp
56
52
  #
57
53
  # Returning nil tells callers to keep searching; the resolver will raise
58
54
  # a FileError if no variant yields coverage data.
59
- def lines_from_entry(entry)
55
+ private def lines_from_entry(entry)
60
56
  return unless entry.is_a?(Hash)
61
57
 
62
58
  lines = entry['lines']
@@ -75,7 +71,7 @@ module SimpleCovMcp
75
71
  # We care about the third tuple element (line number). We sum branch-leg
76
72
  # hits per line so the synthetic array still behaves like legacy line
77
73
  # coverage (any positive value counts as executed).
78
- def synthesize_lines_from_branches(branch_data)
74
+ private def synthesize_lines_from_branches(branch_data)
79
75
  # Detailed shape and rationale documented in docs/BRANCH_ONLY_COVERAGE.md
80
76
  return unless branch_data.is_a?(Hash) && branch_data.any?
81
77
 
@@ -105,7 +101,7 @@ module SimpleCovMcp
105
101
  # Branch metadata arrives as either the raw SimpleCov array
106
102
  # (e.g. [:if, 0, 12, 4, 20, 29]) or the stringified JSON version
107
103
  # ("[:if, 0, 12, 4, 20, 29]"). We normalize both forms and pull the line.
108
- def extract_line_number(meta)
104
+ private def extract_line_number(meta)
109
105
  if meta.is_a?(Array)
110
106
  line_token = meta[2]
111
107
  # Integer(..., exception: false) returns nil on failure, so malformed
@@ -27,9 +27,7 @@ module SimpleCovMcp
27
27
  resolve_fallback or raise_not_found_error
28
28
  end
29
29
 
30
- private
31
-
32
- def resolve_candidate(path, strict:)
30
+ private def resolve_candidate(path, strict:)
33
31
  return path if File.file?(path)
34
32
  return resolve_directory(path) if File.directory?(path)
35
33
 
@@ -37,24 +35,24 @@ module SimpleCovMcp
37
35
  nil
38
36
  end
39
37
 
40
- def resolve_directory(path)
38
+ private def resolve_directory(path)
41
39
  candidate = File.join(path, '.resultset.json')
42
40
  return candidate if File.file?(candidate)
43
41
 
44
42
  raise "No .resultset.json found in directory: #{path}"
45
43
  end
46
44
 
47
- def raise_not_found_error_for_file(path)
45
+ private def raise_not_found_error_for_file(path)
48
46
  raise "Specified resultset not found: #{path}"
49
47
  end
50
48
 
51
- def resolve_fallback
49
+ private def resolve_fallback
52
50
  @candidates
53
51
  .map { |p| File.absolute_path(p, @root) }
54
52
  .find { |p| File.file?(p) }
55
53
  end
56
54
 
57
- def normalize_resultset_path(resultset)
55
+ private def normalize_resultset_path(resultset)
58
56
  candidate = Pathname.new(resultset)
59
57
  return candidate.cleanpath.to_s if candidate.absolute?
60
58
 
@@ -64,13 +62,13 @@ module SimpleCovMcp
64
62
  File.absolute_path(resultset, @root)
65
63
  end
66
64
 
67
- def within_root?(path)
65
+ private def within_root?(path)
68
66
  normalized_root = Pathname.new(@root).cleanpath.to_s
69
67
  root_with_sep = normalized_root.end_with?(File::SEPARATOR) ? normalized_root : "#{normalized_root}#{File::SEPARATOR}"
70
68
  path == normalized_root || path.start_with?(root_with_sep)
71
69
  end
72
70
 
73
- def raise_not_found_error
71
+ private def raise_not_found_error
74
72
  raise "Could not find .resultset.json under #{@root.inspect}; run tests or set --resultset option"
75
73
  end
76
74
  end
@@ -13,10 +13,11 @@ module SimpleCovMcp
13
13
 
14
14
  class << self
15
15
  def load(resultset_path:)
16
- raw = JSON.parse(File.read(resultset_path))
16
+ raw = JSON.load_file(resultset_path)
17
+
17
18
 
18
19
  suites = extract_suite_entries(raw, resultset_path)
19
- raise CoverageDataError.new("No test suite with coverage data found in resultset file: #{resultset_path}") if suites.empty?
20
+ raise CoverageDataError, "No test suite with coverage data found in resultset file: #{resultset_path}" if suites.empty?
20
21
 
21
22
  coverage_map = build_coverage_map(suites, resultset_path)
22
23
  Result.new(
@@ -26,9 +27,7 @@ module SimpleCovMcp
26
27
  )
27
28
  end
28
29
 
29
- private
30
-
31
- def extract_suite_entries(raw, resultset_path)
30
+ private def extract_suite_entries(raw, resultset_path)
32
31
  raw
33
32
  .select { |_, data| data.is_a?(Hash) && data.key?('coverage') && !data['coverage'].nil? }
34
33
  .map do |name, data|
@@ -41,26 +40,26 @@ module SimpleCovMcp
41
40
  end
42
41
  end
43
42
 
44
- def build_coverage_map(suites, resultset_path)
43
+ private def build_coverage_map(suites, resultset_path)
45
44
  return suites.first&.coverage if suites.length == 1
46
45
 
47
46
  merge_suite_coverages(suites, resultset_path)
48
47
  end
49
48
 
50
- def normalize_suite_coverage(coverage, suite_name:, resultset_path:)
49
+ private def normalize_suite_coverage(coverage, suite_name:, resultset_path:)
51
50
  unless coverage.is_a?(Hash)
52
- raise CoverageDataError.new("Invalid coverage data structure for suite #{suite_name.inspect} in resultset file: #{resultset_path}")
51
+ raise CoverageDataError, "Invalid coverage data structure for suite #{suite_name.inspect} in resultset file: #{resultset_path}"
53
52
  end
54
53
 
55
54
  needs_adaptation = coverage.values.any? { |value| value.is_a?(Array) }
56
55
  return coverage unless needs_adaptation
57
56
 
58
- coverage.each_with_object({}) do |(file, value), acc|
59
- acc[file] = value.is_a?(Array) ? { 'lines' => value } : value
57
+ coverage.transform_values do |value|
58
+ value.is_a?(Array) ? { 'lines' => value } : value
60
59
  end
61
60
  end
62
61
 
63
- def merge_suite_coverages(suites, resultset_path)
62
+ private def merge_suite_coverages(suites, resultset_path)
64
63
  require_simplecov_for_merge!(resultset_path)
65
64
  log_duplicate_suite_names(suites)
66
65
 
@@ -72,30 +71,26 @@ module SimpleCovMcp
72
71
  end
73
72
  end
74
73
 
75
- def require_simplecov_for_merge!(resultset_path)
74
+ private def require_simplecov_for_merge!(resultset_path)
76
75
  require 'simplecov'
77
76
  rescue LoadError
78
- raise CoverageDataError.new(
79
- "Multiple coverage suites detected in #{resultset_path}, but the simplecov gem could not be loaded. Install simplecov to enable suite merging."
80
- )
77
+ raise CoverageDataError, "Multiple coverage suites detected in #{resultset_path}, but the simplecov gem could not be loaded. Install simplecov to enable suite merging."
81
78
  end
82
79
 
83
- def log_duplicate_suite_names(suites)
80
+ private def log_duplicate_suite_names(suites)
84
81
  grouped = suites.group_by(&:name)
85
82
  duplicates = grouped.select { |_, entries| entries.length > 1 }.keys
86
83
  return if duplicates.empty?
87
84
 
88
85
  message = "Merging duplicate coverage suites for #{duplicates.join(', ')}"
89
- CovUtil.log(message)
90
- rescue StandardError
91
- # Logging should never block coverage loading
86
+ CovUtil.safe_log(message)
92
87
  end
93
88
 
94
- def compute_combined_timestamp(suites)
89
+ private def compute_combined_timestamp(suites)
95
90
  suites.map(&:timestamp).compact.max.to_i
96
91
  end
97
92
 
98
- def normalize_coverage_timestamp(timestamp_value, created_at_value)
93
+ private def normalize_coverage_timestamp(timestamp_value, created_at_value)
99
94
  raw = timestamp_value.nil? ? created_at_value : timestamp_value
100
95
  return 0 if raw.nil?
101
96
 
@@ -110,12 +105,12 @@ module SimpleCovMcp
110
105
  log_timestamp_warning(raw)
111
106
  0
112
107
  end
113
- rescue StandardError => e
108
+ rescue => e
114
109
  log_timestamp_warning(raw, e)
115
110
  0
116
111
  end
117
112
 
118
- def normalize_string_timestamp(value)
113
+ private def normalize_string_timestamp(value)
119
114
  str = value.strip
120
115
  return 0 if str.empty?
121
116
 
@@ -126,10 +121,10 @@ module SimpleCovMcp
126
121
  end
127
122
  end
128
123
 
129
- def log_timestamp_warning(raw_value, error = nil)
124
+ private def log_timestamp_warning(raw_value, error = nil)
130
125
  message = "Coverage resultset timestamp could not be parsed: #{raw_value.inspect}"
131
126
  message = "#{message} (#{error.message})" if error
132
- CovUtil.log(message) rescue nil
127
+ CovUtil.safe_log(message)
133
128
  end
134
129
  end
135
130
  end
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'time'
4
- require 'json'
5
4
  require 'pathname'
6
- require 'set'
7
5
  require_relative 'errors'
8
6
  require_relative 'util'
9
7
 
@@ -66,62 +64,68 @@ module SimpleCovMcp
66
64
  return if off?
67
65
 
68
66
  ts = coverage_timestamp
69
- newer = []
70
- deleted = []
71
67
  coverage_files = coverage_map.keys
72
- coverage_files.each do |abs|
73
- if File.file?(abs)
74
- newer << rel(abs) if File.mtime(abs).to_i > ts.to_i
75
- else
76
- deleted << rel(abs)
77
- end
78
- end
79
68
 
80
- missing = []
81
- if @tracked_globs && !Array(@tracked_globs).empty?
82
- patterns = Array(@tracked_globs).map { |g| File.absolute_path(g, @root) }
83
- tracked = patterns.flat_map { |p| Dir.glob(p, File::FNM_EXTGLOB | File::FNM_PATHNAME) }
84
- .select { |p| File.file?(p) }
85
- covered_set = coverage_files.to_set rescue coverage_files
86
- tracked.each do |abs|
87
- missing << rel(abs) unless covered_set.include?(abs)
88
- end
89
- end
69
+ newer, deleted = compute_newer_and_deleted_files(coverage_files, ts)
70
+ missing = compute_missing_files(coverage_files)
90
71
 
91
- if !newer.empty? || !missing.empty? || !deleted.empty?
92
- raise CoverageDataProjectStaleError.new(
93
- nil,
94
- nil,
95
- cov_timestamp: ts,
96
- newer_files: newer,
97
- missing_files: missing,
98
- deleted_files: deleted,
99
- resultset_path: resultset_path
100
- )
101
- end
72
+ return if newer.empty? && missing.empty? && deleted.empty?
73
+
74
+ raise CoverageDataProjectStaleError.new(
75
+ nil,
76
+ nil,
77
+ cov_timestamp: ts,
78
+ newer_files: newer,
79
+ missing_files: missing,
80
+ deleted_files: deleted,
81
+ resultset_path: resultset_path
82
+ )
83
+ end
84
+
85
+ private def compute_newer_and_deleted_files(coverage_files, timestamp)
86
+ existing, deleted_abs = coverage_files.partition { |abs| File.file?(abs) }
87
+
88
+ newer = existing
89
+ .select { |abs| File.mtime(abs).to_i > timestamp.to_i }
90
+ .map { |abs| rel(abs) }
91
+ deleted = deleted_abs.map { |abs| rel(abs) }
92
+
93
+ [newer, deleted]
102
94
  end
103
95
 
104
- private
96
+ # Identifies tracked files that are missing from coverage.
97
+ # Returns array of relative paths for files matched by tracked_globs but not in coverage.
98
+ private def compute_missing_files(coverage_files)
99
+ return [] unless @tracked_globs && Array(@tracked_globs).any?
100
+
101
+ patterns = Array(@tracked_globs).map { |g| File.absolute_path(g, @root) }
102
+ tracked = patterns
103
+ .flat_map { |p| Dir.glob(p, File::FNM_EXTGLOB | File::FNM_PATHNAME) }
104
+ .select { |p| File.file?(p) }
105
+
106
+ covered_set = coverage_files.to_set
107
+ tracked.reject { |abs| covered_set.include?(abs) }.map { |abs| rel(abs) }
108
+ end
105
109
 
106
- def coverage_timestamp
110
+ private def coverage_timestamp
107
111
  @cov_timestamp || 0
108
112
  end
109
113
 
110
- def resultset_path
114
+ private def resultset_path
111
115
  @resultset_path ||= CovUtil.find_resultset(@root, resultset: @resultset)
112
- rescue StandardError
116
+ rescue
113
117
  nil
114
118
  end
115
119
 
116
- def safe_count_lines(path)
120
+ private def safe_count_lines(path)
117
121
  return 0 unless File.file?(path)
118
122
 
119
123
  File.foreach(path).count
120
- rescue StandardError
124
+ rescue
121
125
  0
122
126
  end
123
127
 
124
- def missing_trailing_newline?(path)
128
+ private def missing_trailing_newline?(path)
125
129
  return false unless File.file?(path)
126
130
 
127
131
  File.open(path, 'rb') do |f|
@@ -131,11 +135,11 @@ module SimpleCovMcp
131
135
  f.seek(-1, IO::SEEK_END)
132
136
  f.getbyte != 0x0A
133
137
  end
134
- rescue StandardError
138
+ rescue
135
139
  false
136
140
  end
137
141
 
138
- def rel(path)
142
+ private def rel(path)
139
143
  # Handle relative vs absolute path mismatches that cause ArgumentError
140
144
  Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
141
145
  rescue ArgumentError
@@ -146,7 +150,7 @@ module SimpleCovMcp
146
150
  # Centralized computation of staleness-related details for a single file.
147
151
  # Returns a Hash with keys:
148
152
  # :exists, :file_mtime, :coverage_timestamp, :cov_len, :src_len, :newer, :len_mismatch
149
- def compute_file_staleness_details(file_abs, coverage_lines)
153
+ private def compute_file_staleness_details(file_abs, coverage_lines)
150
154
  coverage_ts = coverage_timestamp
151
155
 
152
156
  exists = File.file?(file_abs)
@@ -164,7 +168,7 @@ module SimpleCovMcp
164
168
  )
165
169
 
166
170
  # Check if the source file has been modified since coverage was generated
167
- len_mismatch = check_length_mismatch(cov_len, adjusted_src_len)
171
+ len_mismatch = length_mismatch?(cov_len, adjusted_src_len)
168
172
  newer = check_file_newer_than_coverage(file_mtime, coverage_ts, len_mismatch)
169
173
 
170
174
  {
@@ -199,7 +203,7 @@ module SimpleCovMcp
199
203
  # - SimpleCov coverage array length: 3
200
204
  # - Missing trailing newline: true
201
205
  # - Adjustment: 4 - 1 = 3 (now matches)
202
- def adjust_line_count_for_missing_newline(file_abs:, exists:, cov_len:, src_len:)
206
+ private def adjust_line_count_for_missing_newline(file_abs:, exists:, cov_len:, src_len:)
203
207
  # Only adjust if:
204
208
  # 1. File exists (can't check newlines for missing files)
205
209
  # 2. Coverage data is present (cov_len > 0)
@@ -219,7 +223,7 @@ module SimpleCovMcp
219
223
  #
220
224
  # Note: Empty coverage (cov_len == 0) is not considered a mismatch, as it may represent
221
225
  # files that were never executed or files that are legitimately empty.
222
- def check_length_mismatch(cov_len, adjusted_src_len)
226
+ private def length_mismatch?(cov_len, adjusted_src_len)
223
227
  cov_len.positive? && adjusted_src_len != cov_len
224
228
  end
225
229
 
@@ -233,7 +237,7 @@ module SimpleCovMcp
233
237
  # The logic: newer &&= !len_mismatch means:
234
238
  # - If len_mismatch is true, set newer to false (length mismatch takes precedence)
235
239
  # - This way, staleness is categorized as either 'T' (time-based) OR 'L' (length-based), not both
236
- def check_file_newer_than_coverage(file_mtime, coverage_ts, len_mismatch)
240
+ private def check_file_newer_than_coverage(file_mtime, coverage_ts, len_mismatch)
237
241
  newer = !!(file_mtime && file_mtime.to_i > coverage_ts.to_i)
238
242
  # If there's a length mismatch, don't also flag as "newer" - the mismatch is more specific
239
243
  newer &&= !len_mismatch
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCovMcp
4
+ # General-purpose table formatter with box-drawing characters
5
+ # Used by commands to create consistent formatted output
6
+ class TableFormatter
7
+ # Format data as a table with box-drawing characters
8
+ # @param headers [Array<String>] Column headers
9
+ # @param rows [Array<Array>] Data rows (each row is an array of cell values)
10
+ # @param alignments [Array<Symbol>] Column alignments (:left, :right, :center)
11
+ # @return [String] Formatted table
12
+ def self.format(headers:, rows:, alignments: nil)
13
+ return 'No data to display' if rows.empty?
14
+
15
+ alignments ||= [:left] * headers.size
16
+ all_rows = [headers] + rows.map { |row| row.map(&:to_s) }
17
+
18
+ # Calculate column widths
19
+ widths = headers.size.times.map do |col|
20
+ all_rows.map { |row| row[col].to_s.length }.max
21
+ end
22
+
23
+ lines = []
24
+ lines << border_line(widths, '┌', '┬', '┐')
25
+ lines << data_row(headers, widths, alignments)
26
+ lines << border_line(widths, '├', '┼', '┤')
27
+ rows.each { |row| lines << data_row(row, widths, alignments) }
28
+ lines << border_line(widths, '└', '┴', '┘')
29
+
30
+ lines.join("\n")
31
+ end
32
+
33
+ # Format a single key-value table (vertical layout)
34
+ # @param data [Hash] Key-value pairs
35
+ # @return [String] Formatted table
36
+ def self.format_vertical(data)
37
+ rows = data.map { |k, v| [k.to_s, v.to_s] }
38
+ format(headers: ['Key', 'Value'], rows: rows, alignments: [:left, :left])
39
+ end
40
+
41
+ private_class_method def self.border_line(widths, left, mid, right)
42
+ segments = widths.map { |w| '─' * (w + 2) }
43
+ left + segments.join(mid) + right
44
+ end
45
+
46
+ private_class_method def self.data_row(cells, widths, alignments)
47
+ formatted = cells.each_with_index.map do |cell, i|
48
+ align_cell(cell.to_s, widths[i], alignments[i])
49
+ end
50
+ "│ #{formatted.join(' │ ')} │"
51
+ end
52
+
53
+ private_class_method def self.align_cell(content, width, alignment)
54
+ case alignment
55
+ when :right
56
+ content.rjust(width)
57
+ when :center
58
+ content.center(width)
59
+ else # :left
60
+ content.ljust(width)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -14,19 +14,8 @@ module SimpleCovMcp
14
14
  Output: JSON {"files": [{"file","covered","total","percentage","stale"}, ...], "counts": {"total", "ok", "stale"}} sorted as requested. "stale" is a string ('M', 'T', 'L') or false.
15
15
  Examples: "List files with the lowest coverage"; "Show repo coverage sorted descending".
16
16
  DESC
17
- input_schema(
18
- type: 'object',
19
- additionalProperties: false,
20
- properties: {
21
- root: {
22
- type: 'string',
23
- description: 'Project root used to resolve relative inputs.',
24
- default: '.'
25
- },
26
- resultset: {
27
- type: 'string',
28
- description: 'Path to the SimpleCov .resultset.json file.'
29
- },
17
+ input_schema(**coverage_schema(
18
+ additional_properties: {
30
19
  sort_order: {
31
20
  type: 'string',
32
21
  description: 'Sort order for coverage percentages.' \
@@ -34,46 +23,27 @@ module SimpleCovMcp
34
23
  default: 'ascending',
35
24
  enum: ['ascending', 'descending']
36
25
  },
37
- stale: {
38
- type: 'string',
39
- description:
40
- "How to handle missing/outdated coverage data. 'off' skips checks; 'error' raises.",
41
- enum: ['off', 'error'],
42
- default: 'off'
43
- },
44
- tracked_globs: {
45
- type: 'array',
46
- description: 'Glob patterns for files that should exist in the coverage report' \
47
- '(helps flag new files).',
48
- items: { type: 'string' }
49
- },
50
- error_mode: {
51
- type: 'string',
52
- description:
53
- "Error handling mode: 'off' (silent), 'on' (log errors), 'trace' (verbose).",
54
- enum: ['off', 'on', 'trace'],
55
- default: 'on'
56
- }
26
+ tracked_globs: TRACKED_GLOBS_PROPERTY
57
27
  }
58
- )
28
+ ))
59
29
  class << self
60
- def call(root: '.', resultset: nil, sort_order: 'ascending', stale: 'off',
61
- tracked_globs: nil, error_mode: 'on', server_context:)
62
- # Convert string inputs from MCP to symbols for internal use
63
- sort_order_sym = sort_order.to_sym
64
- stale_sym = stale.to_sym
30
+ def call(root: '.', resultset: nil, sort_order: 'ascending', staleness: :off,
31
+ tracked_globs: nil, error_mode: 'log', server_context:)
32
+ with_error_handling('AllFilesCoverageTool', error_mode: error_mode) do
33
+ # Convert string inputs from MCP to symbols for internal use
34
+ sort_order_sym = sort_order.to_sym
35
+ staleness_sym = staleness.to_sym
65
36
 
66
- model = CoverageModel.new(root: root, resultset: resultset, staleness: stale_sym,
67
- tracked_globs: tracked_globs)
68
- presenter = Presenters::ProjectCoveragePresenter.new(
69
- model: model,
70
- sort_order: sort_order_sym,
71
- check_stale: (stale_sym == :error),
72
- tracked_globs: tracked_globs
73
- )
74
- respond_json(presenter.relativized_payload, name: 'all_files_coverage.json')
75
- rescue => e
76
- handle_mcp_error(e, 'AllFilesCoverageTool', error_mode: error_mode)
37
+ model = CoverageModel.new(root: root, resultset: resultset, staleness: staleness_sym,
38
+ tracked_globs: tracked_globs)
39
+ presenter = Presenters::ProjectCoveragePresenter.new(
40
+ model: model,
41
+ sort_order: sort_order_sym,
42
+ check_stale: (staleness_sym == :error),
43
+ tracked_globs: tracked_globs
44
+ )
45
+ respond_json(presenter.relativized_payload, name: 'all_files_coverage.json')
46
+ end
77
47
  end
78
48
  end
79
49
  end
@@ -10,18 +10,24 @@ module SimpleCovMcp
10
10
  description <<~DESC
11
11
  Use this when the user needs per-line coverage data for a single file.
12
12
  Do not use this for high-level counts; coverage.summary is cheaper for aggregate numbers.
13
- Inputs: file path (required) plus optional root/resultset/stale mode inherited from BaseTool.
13
+ Inputs: file path (required) plus optional root/resultset/staleness mode inherited from BaseTool.
14
14
  Output: JSON object with "file", "lines" => [{"line": 12, "hits": 0, "covered": false}], plus "summary" with totals and "stale" status.
15
15
  Example: "Show detailed coverage for lib/simple_cov_mcp/model.rb".
16
16
  DESC
17
17
  input_schema(**input_schema_def)
18
18
  class << self
19
- def call(path:, root: '.', resultset: nil, stale: 'off', error_mode: 'on', server_context:)
20
- model = CoverageModel.new(root: root, resultset: resultset, staleness: stale)
21
- presenter = Presenters::CoverageDetailedPresenter.new(model: model, path: path)
22
- respond_json(presenter.relativized_payload, name: 'coverage_detailed.json', pretty: true)
23
- rescue => e
24
- handle_mcp_error(e, 'CoverageDetailedTool', error_mode: error_mode)
19
+ def call(path:, root: '.', resultset: nil, staleness: :off, error_mode: 'log',
20
+ server_context:)
21
+ with_error_handling('CoverageDetailedTool', error_mode: error_mode) do
22
+ model = CoverageModel.new(
23
+ root: root,
24
+ resultset: resultset,
25
+ staleness: staleness.to_sym
26
+ )
27
+ presenter = Presenters::CoverageDetailedPresenter.new(model: model, path: path)
28
+ respond_json(presenter.relativized_payload, name: 'coverage_detailed.json',
29
+ pretty: true)
30
+ end
25
31
  end
26
32
  end
27
33
  end
@@ -10,18 +10,23 @@ module SimpleCovMcp
10
10
  description <<~DESC
11
11
  Use this when you need the raw SimpleCov `lines` array for a file exactly as stored on disk.
12
12
  Do not use this for human-friendly explanations; choose coverage.detailed or coverage.summary instead.
13
- Inputs: file path (required) plus optional root/resultset/stale mode inherited from BaseTool.
13
+ Inputs: file path (required) plus optional root/resultset/staleness mode inherited from BaseTool.
14
14
  Output: JSON object with "file" and "lines" (array of integers/nulls) mirroring SimpleCov's native structure, plus "stale" status.
15
15
  Example: "Fetch the raw coverage array for spec/support/foo_helper.rb".
16
16
  DESC
17
17
  input_schema(**input_schema_def)
18
18
  class << self
19
- def call(path:, root: '.', resultset: nil, stale: 'off', error_mode: 'on', server_context:)
20
- model = CoverageModel.new(root: root, resultset: resultset, staleness: stale)
21
- presenter = Presenters::CoverageRawPresenter.new(model: model, path: path)
22
- respond_json(presenter.relativized_payload, name: 'coverage_raw.json', pretty: true)
23
- rescue => e
24
- handle_mcp_error(e, 'CoverageRawTool', error_mode: error_mode)
19
+ def call(path:, root: '.', resultset: nil, staleness: :off, error_mode: 'log',
20
+ server_context:)
21
+ with_error_handling('CoverageRawTool', error_mode: error_mode) do
22
+ model = CoverageModel.new(
23
+ root: root,
24
+ resultset: resultset,
25
+ staleness: staleness.to_sym
26
+ )
27
+ presenter = Presenters::CoverageRawPresenter.new(model: model, path: path)
28
+ respond_json(presenter.relativized_payload, name: 'coverage_raw.json', pretty: true)
29
+ end
25
30
  end
26
31
  end
27
32
  end