simplecov-mcp 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +173 -356
  4. data/docs/ADVANCED_USAGE.md +967 -0
  5. data/docs/ARCHITECTURE.md +79 -0
  6. data/docs/BRANCH_ONLY_COVERAGE.md +81 -0
  7. data/docs/CLI_USAGE.md +637 -0
  8. data/docs/DEVELOPMENT.md +82 -0
  9. data/docs/ERROR_HANDLING.md +93 -0
  10. data/docs/EXAMPLES.md +430 -0
  11. data/docs/INSTALLATION.md +352 -0
  12. data/docs/LIBRARY_API.md +635 -0
  13. data/docs/MCP_INTEGRATION.md +488 -0
  14. data/docs/TROUBLESHOOTING.md +276 -0
  15. data/docs/arch-decisions/001-x-arch-decision.md +93 -0
  16. data/docs/arch-decisions/002-x-arch-decision.md +157 -0
  17. data/docs/arch-decisions/003-x-arch-decision.md +163 -0
  18. data/docs/arch-decisions/004-x-arch-decision.md +199 -0
  19. data/docs/arch-decisions/005-x-arch-decision.md +187 -0
  20. data/docs/arch-decisions/README.md +60 -0
  21. data/docs/presentations/simplecov-mcp-presentation.md +249 -0
  22. data/exe/simplecov-mcp +4 -4
  23. data/lib/simplecov_mcp/app_context.rb +26 -0
  24. data/lib/simplecov_mcp/base_tool.rb +74 -0
  25. data/lib/simplecov_mcp/cli.rb +234 -0
  26. data/lib/simplecov_mcp/cli_config.rb +56 -0
  27. data/lib/simplecov_mcp/commands/base_command.rb +78 -0
  28. data/lib/simplecov_mcp/commands/command_factory.rb +39 -0
  29. data/lib/simplecov_mcp/commands/detailed_command.rb +24 -0
  30. data/lib/simplecov_mcp/commands/list_command.rb +13 -0
  31. data/lib/simplecov_mcp/commands/raw_command.rb +22 -0
  32. data/lib/simplecov_mcp/commands/summary_command.rb +24 -0
  33. data/lib/simplecov_mcp/commands/uncovered_command.rb +26 -0
  34. data/lib/simplecov_mcp/commands/version_command.rb +18 -0
  35. data/lib/simplecov_mcp/constants.rb +22 -0
  36. data/lib/simplecov_mcp/error_handler.rb +124 -0
  37. data/lib/simplecov_mcp/error_handler_factory.rb +31 -0
  38. data/lib/simplecov_mcp/errors.rb +179 -0
  39. data/lib/simplecov_mcp/formatters/source_formatter.rb +148 -0
  40. data/lib/simplecov_mcp/mcp_server.rb +40 -0
  41. data/lib/simplecov_mcp/mode_detector.rb +55 -0
  42. data/lib/simplecov_mcp/model.rb +300 -0
  43. data/lib/simplecov_mcp/option_normalizers.rb +92 -0
  44. data/lib/simplecov_mcp/option_parser_builder.rb +134 -0
  45. data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +50 -0
  46. data/lib/simplecov_mcp/option_parsers/error_helper.rb +109 -0
  47. data/lib/simplecov_mcp/path_relativizer.rb +61 -0
  48. data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +44 -0
  49. data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +16 -0
  50. data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +16 -0
  51. data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +16 -0
  52. data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +16 -0
  53. data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +52 -0
  54. data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +126 -0
  55. data/lib/simplecov_mcp/resolvers/resolver_factory.rb +28 -0
  56. data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +78 -0
  57. data/lib/simplecov_mcp/resultset_loader.rb +136 -0
  58. data/lib/simplecov_mcp/staleness_checker.rb +243 -0
  59. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/all_files_coverage_tool.rb +31 -13
  60. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_detailed_tool.rb +7 -7
  61. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_raw_tool.rb +7 -7
  62. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_summary_tool.rb +7 -7
  63. data/lib/simplecov_mcp/tools/coverage_table_tool.rb +90 -0
  64. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/help_tool.rb +13 -4
  65. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/uncovered_lines_tool.rb +7 -7
  66. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/version_tool.rb +11 -3
  67. data/lib/simplecov_mcp/util.rb +82 -0
  68. data/lib/{simple_cov_mcp → simplecov_mcp}/version.rb +1 -1
  69. data/lib/simplecov_mcp.rb +144 -2
  70. data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
  71. data/spec/TIMESTAMPS.md +48 -0
  72. data/spec/all_files_coverage_tool_spec.rb +29 -25
  73. data/spec/base_tool_spec.rb +11 -10
  74. data/spec/cli/show_default_report_spec.rb +33 -0
  75. data/spec/cli_config_spec.rb +137 -0
  76. data/spec/cli_enumerated_options_spec.rb +68 -0
  77. data/spec/cli_error_spec.rb +105 -47
  78. data/spec/cli_source_spec.rb +82 -23
  79. data/spec/cli_spec.rb +140 -5
  80. data/spec/cli_success_predicate_spec.rb +141 -0
  81. data/spec/cli_table_spec.rb +1 -1
  82. data/spec/cli_usage_spec.rb +10 -26
  83. data/spec/commands/base_command_spec.rb +187 -0
  84. data/spec/commands/command_factory_spec.rb +72 -0
  85. data/spec/commands/detailed_command_spec.rb +48 -0
  86. data/spec/commands/raw_command_spec.rb +46 -0
  87. data/spec/commands/summary_command_spec.rb +47 -0
  88. data/spec/commands/uncovered_command_spec.rb +49 -0
  89. data/spec/constants_spec.rb +61 -0
  90. data/spec/coverage_table_tool_spec.rb +17 -33
  91. data/spec/error_handler_spec.rb +22 -13
  92. data/spec/error_mode_spec.rb +143 -0
  93. data/spec/errors_edge_cases_spec.rb +239 -0
  94. data/spec/errors_stale_spec.rb +2 -2
  95. data/spec/file_based_mcp_tools_spec.rb +99 -0
  96. data/spec/fixtures/project1/lib/bar.rb +0 -1
  97. data/spec/fixtures/project1/lib/foo.rb +0 -1
  98. data/spec/help_tool_spec.rb +11 -17
  99. data/spec/integration_spec.rb +845 -0
  100. data/spec/logging_fallback_spec.rb +128 -0
  101. data/spec/mcp_logging_spec.rb +44 -0
  102. data/spec/mcp_server_integration_spec.rb +23 -0
  103. data/spec/mcp_server_spec.rb +15 -4
  104. data/spec/mode_detector_spec.rb +148 -0
  105. data/spec/model_error_handling_spec.rb +210 -0
  106. data/spec/model_staleness_spec.rb +40 -10
  107. data/spec/option_normalizers_spec.rb +204 -0
  108. data/spec/option_parsers/env_options_parser_spec.rb +233 -0
  109. data/spec/option_parsers/error_helper_spec.rb +222 -0
  110. data/spec/path_relativizer_spec.rb +83 -0
  111. data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
  112. data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
  113. data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
  114. data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
  115. data/spec/presenters/project_coverage_presenter_spec.rb +86 -0
  116. data/spec/resolvers/coverage_line_resolver_spec.rb +57 -0
  117. data/spec/resolvers/resolver_factory_spec.rb +61 -0
  118. data/spec/resolvers/resultset_path_resolver_spec.rb +55 -0
  119. data/spec/resultset_loader_spec.rb +167 -0
  120. data/spec/shared_examples/README.md +115 -0
  121. data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
  122. data/spec/shared_examples/file_based_mcp_tools.rb +174 -0
  123. data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
  124. data/spec/simple_cov_mcp_module_spec.rb +16 -0
  125. data/spec/simplecov_mcp_model_spec.rb +340 -9
  126. data/spec/simplecov_mcp_opts_spec.rb +182 -0
  127. data/spec/spec_helper.rb +147 -4
  128. data/spec/staleness_checker_spec.rb +373 -0
  129. data/spec/staleness_more_spec.rb +16 -13
  130. data/spec/support/mcp_runner.rb +64 -0
  131. data/spec/tools_error_handling_spec.rb +144 -0
  132. data/spec/util_spec.rb +109 -34
  133. data/spec/version_spec.rb +117 -9
  134. data/spec/version_tool_spec.rb +131 -10
  135. metadata +120 -63
  136. data/lib/simple_cov/mcp.rb +0 -9
  137. data/lib/simple_cov_mcp/base_tool.rb +0 -70
  138. data/lib/simple_cov_mcp/cli.rb +0 -390
  139. data/lib/simple_cov_mcp/error_handler.rb +0 -131
  140. data/lib/simple_cov_mcp/error_handler_factory.rb +0 -38
  141. data/lib/simple_cov_mcp/errors.rb +0 -176
  142. data/lib/simple_cov_mcp/mcp_server.rb +0 -30
  143. data/lib/simple_cov_mcp/model.rb +0 -104
  144. data/lib/simple_cov_mcp/staleness_checker.rb +0 -125
  145. data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +0 -61
  146. data/lib/simple_cov_mcp/util.rb +0 -122
  147. data/lib/simple_cov_mcp.rb +0 -102
  148. data/spec/coverage_detailed_tool_spec.rb +0 -36
  149. data/spec/coverage_raw_tool_spec.rb +0 -32
  150. data/spec/coverage_summary_tool_spec.rb +0 -39
  151. data/spec/legacy_shim_spec.rb +0 -13
  152. data/spec/uncovered_lines_tool_spec.rb +0 -33
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'json'
5
+ require 'pathname'
6
+ require 'set'
7
+ require_relative 'errors'
8
+ require_relative 'util'
9
+
10
+ module SimpleCovMcp
11
+ # Lightweight service object to check staleness of coverage vs. sources
12
+ class StalenessChecker
13
+ MODES = [:off, :error].freeze
14
+
15
+ def initialize(root:, resultset:, mode: :off, tracked_globs: nil, timestamp: nil)
16
+ @root = File.absolute_path(root || '.')
17
+ @resultset = resultset
18
+ @mode = (mode || :off).to_sym
19
+ @tracked_globs = tracked_globs
20
+ @cov_timestamp = timestamp
21
+ @resultset_path = nil
22
+ end
23
+
24
+ def off?
25
+ @mode == :off
26
+ end
27
+
28
+ # Raise CoverageDataStaleError if stale (only in error mode)
29
+ def check_file!(file_abs, coverage_lines)
30
+ return if off?
31
+
32
+ d = compute_file_staleness_details(file_abs, coverage_lines)
33
+ # For single-file checks, missing files with recorded coverage count as stale
34
+ # via length mismatch; project-level checks also handle deleted files explicitly.
35
+ if d[:newer] || d[:len_mismatch]
36
+ raise CoverageDataStaleError.new(
37
+ nil,
38
+ nil,
39
+ file_path: rel(file_abs),
40
+ file_mtime: d[:file_mtime],
41
+ cov_timestamp: d[:coverage_timestamp],
42
+ src_len: d[:src_len],
43
+ cov_len: d[:cov_len],
44
+ resultset_path: resultset_path
45
+ )
46
+ end
47
+ end
48
+
49
+ # Compute whether a specific file appears stale relative to coverage.
50
+ # Ignores mode and never raises; returns true when:
51
+ # - the file is missing/deleted, or
52
+ # - the file mtime is newer than the coverage timestamp, or
53
+ # - the source line count differs from the coverage lines array length (when present).
54
+ def stale_for_file?(file_abs, coverage_lines)
55
+ d = compute_file_staleness_details(file_abs, coverage_lines)
56
+ return 'M' unless d[:exists]
57
+ return 'T' if d[:newer]
58
+ return 'L' if d[:len_mismatch]
59
+
60
+ false
61
+ end
62
+
63
+ # Raise CoverageDataProjectStaleError if any covered file is newer or if
64
+ # tracked files are missing from coverage, or coverage includes deleted files.
65
+ def check_project!(coverage_map)
66
+ return if off?
67
+
68
+ ts = coverage_timestamp
69
+ newer = []
70
+ deleted = []
71
+ 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
+
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
90
+
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
102
+ end
103
+
104
+ private
105
+
106
+ def coverage_timestamp
107
+ @cov_timestamp || 0
108
+ end
109
+
110
+ def resultset_path
111
+ @resultset_path ||= CovUtil.find_resultset(@root, resultset: @resultset)
112
+ rescue StandardError
113
+ nil
114
+ end
115
+
116
+ def safe_count_lines(path)
117
+ return 0 unless File.file?(path)
118
+
119
+ File.foreach(path).count
120
+ rescue StandardError
121
+ 0
122
+ end
123
+
124
+ def missing_trailing_newline?(path)
125
+ return false unless File.file?(path)
126
+
127
+ File.open(path, 'rb') do |f|
128
+ size = f.size
129
+ return false if size.zero?
130
+
131
+ f.seek(-1, IO::SEEK_END)
132
+ f.getbyte != 0x0A
133
+ end
134
+ rescue StandardError
135
+ false
136
+ end
137
+
138
+ def rel(path)
139
+ # Handle relative vs absolute path mismatches that cause ArgumentError
140
+ Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
141
+ rescue ArgumentError
142
+ # Path is outside the project root or has a different prefix type, fall back to absolute path
143
+ path.to_s
144
+ end
145
+
146
+ # Centralized computation of staleness-related details for a single file.
147
+ # Returns a Hash with keys:
148
+ # :exists, :file_mtime, :coverage_timestamp, :cov_len, :src_len, :newer, :len_mismatch
149
+ def compute_file_staleness_details(file_abs, coverage_lines)
150
+ coverage_ts = coverage_timestamp
151
+
152
+ exists = File.file?(file_abs)
153
+ file_mtime = exists ? File.mtime(file_abs) : nil
154
+
155
+ cov_len = coverage_lines.respond_to?(:length) ? coverage_lines.length : 0
156
+ src_len = exists ? safe_count_lines(file_abs) : 0
157
+
158
+ # Adjust source line count to handle edge cases with missing trailing newlines
159
+ adjusted_src_len = adjust_line_count_for_missing_newline(
160
+ file_abs: file_abs,
161
+ exists: exists,
162
+ cov_len: cov_len,
163
+ src_len: src_len
164
+ )
165
+
166
+ # Check if the source file has been modified since coverage was generated
167
+ len_mismatch = check_length_mismatch(cov_len, adjusted_src_len)
168
+ newer = check_file_newer_than_coverage(file_mtime, coverage_ts, len_mismatch)
169
+
170
+ {
171
+ exists: exists,
172
+ file_mtime: file_mtime,
173
+ coverage_timestamp: coverage_ts,
174
+ cov_len: cov_len,
175
+ src_len: src_len,
176
+ newer: newer,
177
+ len_mismatch: len_mismatch
178
+ }
179
+ end
180
+
181
+ # Adjusts the source line count to account for files missing trailing newlines.
182
+ #
183
+ # Why this edge case exists:
184
+ # - File.foreach counts lines by separator (typically \n)
185
+ # - For a file with no trailing newline, File.foreach still counts all lines correctly
186
+ # - However, some editors or file operations may report one extra line when checking
187
+ # if the file doesn't end with a newline
188
+ # - SimpleCov's coverage array length matches the logical line count (excluding trailing newline)
189
+ # - If src_len is exactly one more than cov_len AND the file is missing a trailing newline,
190
+ # we adjust src_len down by 1 to match SimpleCov's convention
191
+ #
192
+ # Example: A file with "line1\nline2\nline3" (no final \n)
193
+ # - File.foreach counts: 3 lines
194
+ # - SimpleCov coverage array length: 3
195
+ # - No adjustment needed
196
+ #
197
+ # However, in certain edge cases where the file system or parsing reports an extra line:
198
+ # - Reported line count: 4
199
+ # - SimpleCov coverage array length: 3
200
+ # - Missing trailing newline: true
201
+ # - Adjustment: 4 - 1 = 3 (now matches)
202
+ def adjust_line_count_for_missing_newline(file_abs:, exists:, cov_len:, src_len:)
203
+ # Only adjust if:
204
+ # 1. File exists (can't check newlines for missing files)
205
+ # 2. Coverage data is present (cov_len > 0)
206
+ # 3. Source has exactly one more line than coverage
207
+ # 4. File is missing a trailing newline
208
+ needs_adjusting =
209
+ exists && cov_len.positive? && src_len == cov_len + 1 && missing_trailing_newline?(file_abs)
210
+ needs_adjusting ? src_len - 1 : src_len
211
+ end
212
+
213
+ # Checks if the source line count differs from the coverage line count.
214
+ #
215
+ # Why this check exists:
216
+ # - When a file is modified after coverage is generated, the line count often changes
217
+ # - A mismatch indicates the coverage data is stale and no longer represents the current file
218
+ # - We only flag as mismatch when coverage data exists (cov_len > 0)
219
+ #
220
+ # Note: Empty coverage (cov_len == 0) is not considered a mismatch, as it may represent
221
+ # files that were never executed or files that are legitimately empty.
222
+ def check_length_mismatch(cov_len, adjusted_src_len)
223
+ cov_len.positive? && adjusted_src_len != cov_len
224
+ end
225
+
226
+ # Determines if a file has been modified more recently than the coverage timestamp.
227
+ #
228
+ # Why this check exists:
229
+ # - Files modified after coverage generation may have behavioral changes not captured
230
+ # - However, if there's already a length mismatch, we prioritize that as the staleness indicator
231
+ # - This prevents double-flagging: if lines changed, the file is already stale (length mismatch)
232
+ #
233
+ # The logic: newer &&= !len_mismatch means:
234
+ # - If len_mismatch is true, set newer to false (length mismatch takes precedence)
235
+ # - 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)
237
+ newer = !!(file_mtime && file_mtime.to_i > coverage_ts.to_i)
238
+ # If there's a length mismatch, don't also flag as "newer" - the mismatch is more specific
239
+ newer &&= !len_mismatch
240
+ newer
241
+ end
242
+ end
243
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative '../model'
4
4
  require_relative '../base_tool'
5
+ require_relative '../presenters/project_coverage_presenter'
5
6
 
6
7
  module SimpleCovMcp
7
8
  module Tools
@@ -10,7 +11,7 @@ module SimpleCovMcp
10
11
  Use this when the user wants coverage percentages for every tracked file in the project.
11
12
  Do not use this for single-file stats; prefer coverage.summary or coverage.uncovered_lines for that.
12
13
  Inputs: optional project root, alternate .resultset path, sort order, staleness mode, and tracked_globs to alert on new files.
13
- Output: JSON {"files": [{"file","covered","total","percentage","stale"}, ...], "counts": {"total", "ok", "stale"}} sorted as requested. "stale" is a boolean.
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.
14
15
  Examples: "List files with the lowest coverage"; "Show repo coverage sorted descending".
15
16
  DESC
16
17
  input_schema(
@@ -28,34 +29,51 @@ module SimpleCovMcp
28
29
  },
29
30
  sort_order: {
30
31
  type: 'string',
31
- description: "Sort order for coverage percentages. 'ascending' highlights the riskiest files first.",
32
+ description: 'Sort order for coverage percentages.' \
33
+ "'ascending' highlights the riskiest files first.",
32
34
  default: 'ascending',
33
35
  enum: ['ascending', 'descending']
34
36
  },
35
37
  stale: {
36
38
  type: 'string',
37
- description: "How to handle missing/outdated coverage data. 'off' skips checks; 'error' raises.",
39
+ description:
40
+ "How to handle missing/outdated coverage data. 'off' skips checks; 'error' raises.",
38
41
  enum: ['off', 'error'],
39
42
  default: 'off'
40
43
  },
41
44
  tracked_globs: {
42
45
  type: 'array',
43
- description: 'Glob patterns for files that should exist in the coverage report (helps flag new files).',
46
+ description: 'Glob patterns for files that should exist in the coverage report' \
47
+ '(helps flag new files).',
44
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'
45
56
  }
46
57
  }
47
58
  )
48
59
  class << self
49
- def call(root: '.', resultset: nil, sort_order: 'ascending', stale: 'off', tracked_globs: nil, server_context:)
50
- model = CoverageModel.new(root: root, resultset: resultset, staleness: stale, tracked_globs: tracked_globs)
51
- files = model.all_files(sort_order: sort_order, check_stale: (stale.to_s == 'error'), tracked_globs: tracked_globs)
52
- total = files.length
53
- stale_count = files.count { |f| f['stale'] }
54
- ok_count = total - stale_count
55
- payload = { files: files, counts: { total: total, ok: ok_count, stale: stale_count } }
56
- respond_json(payload, name: 'all_files_coverage.json')
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
65
+
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')
57
75
  rescue => e
58
- handle_mcp_error(e, 'AllFilesCoverageTool')
76
+ handle_mcp_error(e, 'AllFilesCoverageTool', error_mode: error_mode)
59
77
  end
60
78
  end
61
79
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative '../base_tool'
4
4
  require_relative '../model'
5
+ require_relative '../presenters/coverage_detailed_presenter'
5
6
 
6
7
  module SimpleCovMcp
7
8
  module Tools
@@ -10,18 +11,17 @@ module SimpleCovMcp
10
11
  Use this when the user needs per-line coverage data for a single file.
11
12
  Do not use this for high-level counts; coverage.summary is cheaper for aggregate numbers.
12
13
  Inputs: file path (required) plus optional root/resultset/stale mode inherited from BaseTool.
13
- Output: JSON object with "file", "lines" => [{"line": 12, "hits": 0, "covered": false}], plus "summary" with totals.
14
+ Output: JSON object with "file", "lines" => [{"line": 12, "hits": 0, "covered": false}], plus "summary" with totals and "stale" status.
14
15
  Example: "Show detailed coverage for lib/simple_cov_mcp/model.rb".
15
16
  DESC
16
17
  input_schema(**input_schema_def)
17
18
  class << self
18
- def call(path:, root: '.', resultset: nil, stale: 'off', server_context:)
19
- mode = stale
20
- model = CoverageModel.new(root: root, resultset: resultset, staleness: mode)
21
- data = model.detailed_for(path)
22
- respond_json(data, name: 'coverage_detailed.json', pretty: true)
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
23
  rescue => e
24
- handle_mcp_error(e, 'CoverageDetailedTool')
24
+ handle_mcp_error(e, 'CoverageDetailedTool', error_mode: error_mode)
25
25
  end
26
26
  end
27
27
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative '../base_tool'
4
4
  require_relative '../model'
5
+ require_relative '../presenters/coverage_raw_presenter'
5
6
 
6
7
  module SimpleCovMcp
7
8
  module Tools
@@ -10,18 +11,17 @@ module SimpleCovMcp
10
11
  Use this when you need the raw SimpleCov `lines` array for a file exactly as stored on disk.
11
12
  Do not use this for human-friendly explanations; choose coverage.detailed or coverage.summary instead.
12
13
  Inputs: file path (required) plus optional root/resultset/stale mode inherited from BaseTool.
13
- Output: JSON object with "file" and "lines" (array of integers/nulls) mirroring SimpleCov's native structure.
14
+ Output: JSON object with "file" and "lines" (array of integers/nulls) mirroring SimpleCov's native structure, plus "stale" status.
14
15
  Example: "Fetch the raw coverage array for spec/support/foo_helper.rb".
15
16
  DESC
16
17
  input_schema(**input_schema_def)
17
18
  class << self
18
- def call(path:, root: '.', resultset: nil, stale: 'off', server_context:)
19
- mode = stale
20
- model = CoverageModel.new(root: root, resultset: resultset, staleness: mode)
21
- data = model.raw_for(path)
22
- respond_json(data, name: 'coverage_raw.json', pretty: true)
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
23
  rescue => e
24
- handle_mcp_error(e, 'CoverageRawTool')
24
+ handle_mcp_error(e, 'CoverageRawTool', error_mode: error_mode)
25
25
  end
26
26
  end
27
27
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative '../base_tool'
4
4
  require_relative '../model'
5
+ require_relative '../presenters/coverage_summary_presenter'
5
6
 
6
7
  module SimpleCovMcp
7
8
  module Tools
@@ -10,18 +11,17 @@ module SimpleCovMcp
10
11
  Use this when the user asks for the covered/total line counts and percentage for a specific file.
11
12
  Do not use this for multi-file reports; coverage.all_files or coverage.table handle those.
12
13
  Inputs: file path (required) plus optional root/resultset/stale mode inherited from BaseTool.
13
- Output: JSON object {"file": String, "summary": {"covered": Integer, "total": Integer, "pct": Float}}.
14
+ Output: JSON object {"file": String, "summary": {"covered": Integer, "total": Integer, "pct": Float}, "stale": String|False}.
14
15
  Examples: "What is the coverage for lib/simple_cov_mcp/tools/all_files_coverage_tool.rb?".
15
16
  DESC
16
17
  input_schema(**input_schema_def)
17
18
  class << self
18
- def call(path:, root: '.', resultset: nil, stale: 'off', server_context:)
19
- mode = stale
20
- model = CoverageModel.new(root: root, resultset: resultset, staleness: mode)
21
- data = model.summary_for(path)
22
- respond_json(data, name: 'coverage_summary.json', pretty: true)
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::CoverageSummaryPresenter.new(model: model, path: path)
22
+ respond_json(presenter.relativized_payload, name: 'coverage_summary.json', pretty: true)
23
23
  rescue => e
24
- handle_mcp_error(e, 'CoverageSummaryTool')
24
+ handle_mcp_error(e, 'CoverageSummaryTool', error_mode: error_mode)
25
25
  end
26
26
  end
27
27
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ require_relative '../base_tool'
5
+ require_relative '../presenters/project_coverage_presenter'
6
+
7
+ module SimpleCovMcp
8
+ module Tools
9
+ class CoverageTableTool < BaseTool
10
+ description <<~DESC
11
+ Use this when a user wants the plain text coverage table exactly like `simplecov-mcp --table` would print (no ANSI colors).
12
+ Do not use this for machine-readable data; coverage.all_files returns structured JSON.
13
+ Inputs: optional project root/resultset path/sort order/staleness mode matching the CLI flags.
14
+ Output: text block containing the formatted coverage table with headers and percentages.
15
+ Example: "Show me the CLI coverage table sorted descending".
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
+ },
30
+ sort_order: {
31
+ type: 'string',
32
+ description: 'Sort order for the printed coverage table (ascending or descending).',
33
+ default: 'ascending',
34
+ enum: ['ascending', 'descending']
35
+ },
36
+ stale: {
37
+ type: 'string',
38
+ description: 'How to handle missing/outdated coverage data. ' \
39
+ "'off' skips checks; 'error' raises.",
40
+ enum: ['off', 'error'],
41
+ default: 'off'
42
+ },
43
+ tracked_globs: {
44
+ type: 'array',
45
+ description: 'Glob patterns for files that should exist in the coverage report ' \
46
+ '(helps flag new files).',
47
+ items: { type: 'string' }
48
+ },
49
+ error_mode: {
50
+ type: 'string',
51
+ description:
52
+ "Error handling mode: 'off' (silent), 'on' (log errors), 'trace' (verbose).",
53
+ enum: ['off', 'on', 'trace'],
54
+ default: 'on'
55
+ }
56
+ }
57
+ )
58
+
59
+ class << self
60
+ def call(root: '.', resultset: nil, sort_order: 'ascending', stale: 'off',
61
+ tracked_globs: nil, error_mode: 'on', server_context:)
62
+ # Capture the output of the CLI's table report while honoring CLI options
63
+ # Convert string inputs from MCP to symbols for internal use
64
+ sort_order_sym = sort_order.to_sym
65
+ stale_sym = stale.to_sym
66
+ check_stale = (stale_sym == :error)
67
+
68
+ model = CoverageModel.new(root: root, resultset: resultset, staleness: stale_sym,
69
+ tracked_globs: tracked_globs)
70
+ presenter = Presenters::ProjectCoveragePresenter.new(
71
+ model: model,
72
+ sort_order: sort_order_sym,
73
+ check_stale: check_stale,
74
+ tracked_globs: tracked_globs
75
+ )
76
+ relativized = presenter.relative_files
77
+ table = model.format_table(
78
+ relativized,
79
+ sort_order: sort_order_sym,
80
+ check_stale: check_stale,
81
+ tracked_globs: nil # rows already filtered via all_files
82
+ )
83
+ ::MCP::Tool::Response.new([{ type: 'text', text: table }])
84
+ rescue => e
85
+ handle_mcp_error(e, 'CoverageTableTool', error_mode: error_mode)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -9,7 +9,8 @@ module SimpleCovMcp
9
9
  Use this when you are unsure which simplecov-mcp tool fits the user’s coverage request.
10
10
  Do not use this once you know the correct tool; call that tool directly.
11
11
  Inputs: optional query string to filter the list of tools.
12
- Output: JSON {"tools": [...]} with per-tool "use_when", "avoid_when", "inputs", and "example" guidance.
12
+ Output: JSON {"tools": [...]} with per-tool "use_when", "avoid_when", "inputs",#{' '}
13
+ and "example" guidance.
13
14
  Example: "Which tool shows uncovered lines?".
14
15
  DESC
15
16
 
@@ -19,7 +20,15 @@ module SimpleCovMcp
19
20
  properties: {
20
21
  query: {
21
22
  type: 'string',
22
- description: 'Optional keywords to filter the help entries (e.g., "uncovered", "summary").'
23
+ description:
24
+ 'Optional keywords to filter the help entries (e.g., "uncovered", "summary").'
25
+ },
26
+ error_mode: {
27
+ type: 'string',
28
+ description:
29
+ "Error handling mode: 'off' (silent), 'on' (log errors), 'trace' (verbose).",
30
+ enum: ['off', 'on', 'trace'],
31
+ default: 'on'
23
32
  }
24
33
  }
25
34
  )
@@ -84,14 +93,14 @@ module SimpleCovMcp
84
93
  ].freeze
85
94
 
86
95
  class << self
87
- def call(query: nil, server_context:, **_unused)
96
+ def call(query: nil, error_mode: 'on', server_context:, **_unused)
88
97
  entries = TOOL_GUIDE.map { |guide| format_entry(guide) }
89
98
  entries = filter_entries(entries, query) if query && !query.strip.empty?
90
99
 
91
100
  data = { query: query, tools: entries }
92
101
  respond_json(data, name: 'tools_help.json')
93
102
  rescue => e
94
- handle_mcp_error(e, 'HelpTool')
103
+ handle_mcp_error(e, 'HelpTool', error_mode: error_mode)
95
104
  end
96
105
 
97
106
  private
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative '../base_tool'
4
4
  require_relative '../model'
5
+ require_relative '../presenters/coverage_uncovered_presenter'
5
6
 
6
7
  module SimpleCovMcp
7
8
  module Tools
@@ -10,18 +11,17 @@ module SimpleCovMcp
10
11
  Use this when the user wants to know which lines in a file still lack coverage.
11
12
  Do not use this for overall percentages; coverage.summary is faster when counts are enough.
12
13
  Inputs: file path (required) plus optional root/resultset/stale mode inherited from BaseTool.
13
- Output: JSON object with keys "file", "uncovered" (array of integers), and "summary" {"covered","total","pct"}.
14
+ Output: JSON object with keys "file", "uncovered" (array of integers), "summary" {"covered","total","pct"}, and "stale" status.
14
15
  Example: "List uncovered lines for lib/simple_cov_mcp/tools/coverage_summary_tool.rb".
15
16
  DESC
16
17
  input_schema(**input_schema_def)
17
18
  class << self
18
- def call(path:, root: '.', resultset: nil, stale: 'off', server_context:)
19
- mode = stale
20
- model = CoverageModel.new(root: root, resultset: resultset, staleness: mode)
21
- data = model.uncovered_for(path)
22
- respond_json(data, name: 'uncovered_lines.json', pretty: true)
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::CoverageUncoveredPresenter.new(model: model, path: path)
22
+ respond_json(presenter.relativized_payload, name: 'uncovered_lines.json', pretty: true)
23
23
  rescue => e
24
- handle_mcp_error(e, 'UncoveredLinesTool')
24
+ handle_mcp_error(e, 'UncoveredLinesTool', error_mode: error_mode)
25
25
  end
26
26
  end
27
27
  end
@@ -14,16 +14,24 @@ module SimpleCovMcp
14
14
  input_schema(
15
15
  type: 'object',
16
16
  additionalProperties: false,
17
- properties: {}
17
+ properties: {
18
+ error_mode: {
19
+ type: 'string',
20
+ description:
21
+ "Error handling mode: 'off' (silent), 'on' (log errors), 'trace' (verbose).",
22
+ enum: ['off', 'on', 'trace'],
23
+ default: 'on'
24
+ }
25
+ }
18
26
  )
19
27
 
20
28
  class << self
21
- def call(server_context: nil, **_args)
29
+ def call(error_mode: 'on', server_context: nil, **_args)
22
30
  ::MCP::Tool::Response.new([
23
31
  { type: 'text', text: "SimpleCovMcp version: #{SimpleCovMcp::VERSION}" }
24
32
  ])
25
33
  rescue => error
26
- handle_mcp_error(error, 'version_tool')
34
+ handle_mcp_error(error, 'version_tool', error_mode: error_mode)
27
35
  end
28
36
  end
29
37
  end