simplecov-mcp 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/README.md +379 -32
- data/exe/simplecov-mcp +19 -2
- data/lib/simple_cov/mcp.rb +9 -0
- data/lib/simple_cov_mcp/base_tool.rb +54 -0
- data/lib/simple_cov_mcp/cli.rb +390 -0
- data/lib/simple_cov_mcp/error_handler.rb +131 -0
- data/lib/simple_cov_mcp/error_handler_factory.rb +38 -0
- data/lib/simple_cov_mcp/errors.rb +176 -0
- data/lib/simple_cov_mcp/mcp_server.rb +30 -0
- data/lib/simple_cov_mcp/model.rb +104 -0
- data/lib/simple_cov_mcp/staleness_checker.rb +125 -0
- data/lib/simple_cov_mcp/tools/all_files_coverage_tool.rb +65 -0
- data/lib/simple_cov_mcp/tools/coverage_detailed_tool.rb +29 -0
- data/lib/simple_cov_mcp/tools/coverage_raw_tool.rb +29 -0
- data/lib/simple_cov_mcp/tools/coverage_summary_tool.rb +29 -0
- data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +61 -0
- data/lib/simple_cov_mcp/tools/help_tool.rb +136 -0
- data/lib/simple_cov_mcp/tools/uncovered_lines_tool.rb +29 -0
- data/lib/simple_cov_mcp/tools/version_tool.rb +31 -0
- data/lib/simple_cov_mcp/util.rb +122 -0
- data/lib/simple_cov_mcp/version.rb +5 -0
- data/lib/simple_cov_mcp.rb +102 -0
- data/lib/simplecov_mcp.rb +2 -3
- data/spec/all_files_coverage_tool_spec.rb +46 -0
- data/spec/base_tool_spec.rb +58 -0
- data/spec/cli_error_spec.rb +103 -0
- data/spec/cli_source_spec.rb +37 -0
- data/spec/cli_spec.rb +72 -0
- data/spec/cli_table_spec.rb +28 -0
- data/spec/cli_usage_spec.rb +58 -0
- data/spec/coverage_table_tool_spec.rb +64 -0
- data/spec/error_handler_spec.rb +72 -0
- data/spec/errors_stale_spec.rb +49 -0
- data/spec/fixtures/project1/lib/bar.rb +4 -0
- data/spec/fixtures/project1/lib/foo.rb +5 -0
- data/spec/help_tool_spec.rb +47 -0
- data/spec/legacy_shim_spec.rb +13 -0
- data/spec/mcp_server_spec.rb +78 -0
- data/spec/model_staleness_spec.rb +49 -0
- data/spec/simplecov_mcp_model_spec.rb +51 -32
- data/spec/spec_helper.rb +37 -7
- data/spec/staleness_more_spec.rb +39 -0
- data/spec/util_spec.rb +78 -0
- data/spec/version_spec.rb +10 -0
- metadata +59 -17
- data/lib/simplecov/mcp/base_tool.rb +0 -18
- data/lib/simplecov/mcp/cli.rb +0 -98
- data/lib/simplecov/mcp/model.rb +0 -59
- data/lib/simplecov/mcp/tools/all_files_coverage.rb +0 -28
- data/lib/simplecov/mcp/tools/coverage_detailed.rb +0 -22
- data/lib/simplecov/mcp/tools/coverage_raw.rb +0 -22
- data/lib/simplecov/mcp/tools/coverage_summary.rb +0 -22
- data/lib/simplecov/mcp/tools/uncovered_lines.rb +0 -22
- data/lib/simplecov/mcp/util.rb +0 -94
- data/lib/simplecov/mcp/version.rb +0 -8
- data/lib/simplecov/mcp.rb +0 -28
@@ -0,0 +1,390 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SimpleCovMcp
|
4
|
+
class CoverageCLI
|
5
|
+
SUBCOMMANDS = %w[list summary raw uncovered detailed version].freeze
|
6
|
+
|
7
|
+
# Initialize CLI for pure CLI usage only.
|
8
|
+
# Always runs as CLI, no mode detection needed.
|
9
|
+
def initialize(error_handler: nil)
|
10
|
+
@root = '.'
|
11
|
+
@resultset = nil
|
12
|
+
@json = false
|
13
|
+
@sort_order = 'ascending'
|
14
|
+
@cmd = nil
|
15
|
+
@cmd_args = []
|
16
|
+
@source_mode = nil # nil, 'full', or 'uncovered'
|
17
|
+
@source_context = 2 # lines of context for uncovered mode
|
18
|
+
@color = STDOUT.tty?
|
19
|
+
@error_handler = error_handler || ErrorHandlerFactory.for_cli
|
20
|
+
@stale_mode = 'off'
|
21
|
+
@tracked_globs = nil
|
22
|
+
end
|
23
|
+
|
24
|
+
def run(argv)
|
25
|
+
parse_options!(argv)
|
26
|
+
if @cmd
|
27
|
+
run_subcommand(@cmd, @cmd_args)
|
28
|
+
else
|
29
|
+
show_default_report(sort_order: @sort_order)
|
30
|
+
end
|
31
|
+
rescue SimpleCovMcp::Error => e
|
32
|
+
handle_user_facing_error(e)
|
33
|
+
rescue => e
|
34
|
+
@error_handler.handle_error(e, context: 'CLI execution')
|
35
|
+
end
|
36
|
+
|
37
|
+
def show_default_report(sort_order: :ascending, output: $stdout)
|
38
|
+
model = CoverageModel.new(root: @root, resultset: @resultset, staleness: @stale_mode, tracked_globs: @tracked_globs)
|
39
|
+
rows = model.all_files(sort_order: sort_order, check_stale: (@stale_mode == 'error'), tracked_globs: @tracked_globs)
|
40
|
+
if @json
|
41
|
+
files = rows.map { |row| row.merge('file' => rel_to_root(row['file'])) }
|
42
|
+
total = files.length
|
43
|
+
stale_count = files.count { |f| f['stale'] }
|
44
|
+
ok_count = total - stale_count
|
45
|
+
output.puts JSON.pretty_generate({ files: files, counts: { total: total, ok: ok_count, stale: stale_count } })
|
46
|
+
return
|
47
|
+
end
|
48
|
+
|
49
|
+
file_summaries = rows.map do |row|
|
50
|
+
row.dup.tap do |h|
|
51
|
+
h['file'] = rel_to_root(h['file'])
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Format as table with box-style borders
|
56
|
+
max_file_length = file_summaries.map { |f| f['file'].length }.max.to_i
|
57
|
+
max_file_length = [max_file_length, 'File'.length].max
|
58
|
+
|
59
|
+
# Calculate maximum numeric values for proper column widths
|
60
|
+
max_covered = file_summaries.map { |f| f['covered'].to_s.length }.max
|
61
|
+
max_total = file_summaries.map { |f| f['total'].to_s.length }.max
|
62
|
+
|
63
|
+
# Define column widths
|
64
|
+
file_width = max_file_length + 2 # Extra padding
|
65
|
+
pct_width = 8
|
66
|
+
covered_width = [max_covered, 'Covered'.length].max + 2
|
67
|
+
total_width = [max_total, 'Total'.length].max + 2
|
68
|
+
|
69
|
+
stale_header = 'Stale'
|
70
|
+
stale_width = stale_header.length
|
71
|
+
# Use String#center for concise centering
|
72
|
+
|
73
|
+
# Horizontal line for each column span
|
74
|
+
h_line = ->(col_width) { '─' * (col_width + 2) }
|
75
|
+
|
76
|
+
# Border line lambda
|
77
|
+
border_line = ->(left, middle, right) {
|
78
|
+
left + h_line.(file_width) +
|
79
|
+
middle + h_line.(pct_width) +
|
80
|
+
middle + h_line.(covered_width) +
|
81
|
+
middle + h_line.(total_width) +
|
82
|
+
middle + h_line.(stale_width) +
|
83
|
+
right
|
84
|
+
}
|
85
|
+
|
86
|
+
# Top border
|
87
|
+
output.puts border_line.call('┌', '┬', '┐')
|
88
|
+
|
89
|
+
# Header row
|
90
|
+
output.printf "│ %-#{file_width}s │ %#{pct_width}s │ %#{covered_width}s │ %#{total_width}s │ %#{stale_width}s │\n",
|
91
|
+
'File', ' %', 'Covered', 'Total', stale_header.to_s.center(stale_width)
|
92
|
+
|
93
|
+
# Header separator
|
94
|
+
output.puts border_line.call('├', '┼', '┤')
|
95
|
+
|
96
|
+
# Data rows
|
97
|
+
file_summaries.each do |file_data|
|
98
|
+
stale_text_str = file_data['stale'] ? '!' : ''
|
99
|
+
output.printf "│ %-#{file_width}s │ %#{pct_width - 1}.2f%% │ %#{covered_width}d │ %#{total_width}d │ %#{stale_width}s │\n",
|
100
|
+
file_data['file'],
|
101
|
+
file_data['percentage'],
|
102
|
+
file_data['covered'],
|
103
|
+
file_data['total'],
|
104
|
+
stale_text_str.center(stale_width)
|
105
|
+
end
|
106
|
+
|
107
|
+
# Bottom border
|
108
|
+
output.puts border_line.call('└', '┴', '┘')
|
109
|
+
|
110
|
+
# Summary counts line
|
111
|
+
total = file_summaries.length
|
112
|
+
stale_count = file_summaries.count { |f| f['stale'] }
|
113
|
+
ok_count = total - stale_count
|
114
|
+
output.puts "Files: total #{total}, ok #{ok_count}, stale #{stale_count}"
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def parse_options!(argv)
|
120
|
+
require 'optparse'
|
121
|
+
|
122
|
+
if !argv.empty? && SUBCOMMANDS.include?(argv[0])
|
123
|
+
@cmd = argv.shift
|
124
|
+
end
|
125
|
+
|
126
|
+
op = OptionParser.new do |o|
|
127
|
+
o.banner = 'Usage: simplecov-mcp [subcommand] [options] [args]'
|
128
|
+
o.separator ''
|
129
|
+
o.separator 'Subcommands:'
|
130
|
+
o.separator ' list Show table of all files'
|
131
|
+
o.separator ' summary <path> Show covered/total/% for a file'
|
132
|
+
o.separator " raw <path> Show the SimpleCov 'lines' array"
|
133
|
+
o.separator ' uncovered <path> Show uncovered lines and a summary'
|
134
|
+
o.separator ' detailed <path> Show per-line rows with hits/covered'
|
135
|
+
o.separator ' version Show version information'
|
136
|
+
o.separator ''
|
137
|
+
|
138
|
+
o.separator ''
|
139
|
+
o.separator 'Options:'
|
140
|
+
o.on('--resultset PATH', String, 'Path or directory that contains .resultset.json') { |v| @resultset = v }
|
141
|
+
o.on('--root PATH', String, "Project root (default '.')") { |v| @root = v }
|
142
|
+
o.on('--json', 'Output JSON for machine consumption') { @json = true }
|
143
|
+
o.on('--sort-order ORDER', String, ['ascending', 'descending'], "Sort order for 'list' (ascending|descending)") { |v| @sort_order = v }
|
144
|
+
o.on('--source[=MODE]', [:full, :uncovered], 'Include source in output for summary/uncovered/detailed (MODE: full|uncovered; default full)') do |v|
|
145
|
+
@source_mode = (v || :full).to_s
|
146
|
+
end
|
147
|
+
o.on('--source-context N', Integer, 'For --source=uncovered, show N context lines (default 2)') { |v| @source_context = v }
|
148
|
+
o.on('--color', 'Enable ANSI colors for source output') { @color = true }
|
149
|
+
o.on('--no-color', 'Disable ANSI colors') { @color = false }
|
150
|
+
o.on('--stale MODE', [:off, :error], "Staleness mode: off|error (default off)") do |v|
|
151
|
+
@stale_mode = v.to_s
|
152
|
+
end
|
153
|
+
o.on('--tracked-globs x,y,z', Array, 'Globs for files that should be covered (list only)') { |v| @tracked_globs = v }
|
154
|
+
o.separator ''
|
155
|
+
o.separator 'Examples:'
|
156
|
+
o.separator ' simplecov-mcp list --resultset coverage'
|
157
|
+
o.separator ' simplecov-mcp summary lib/foo.rb --json --resultset coverage'
|
158
|
+
o.separator ' simplecov-mcp uncovered lib/foo.rb --source=uncovered --source-context 2'
|
159
|
+
o.on('-h', '--help', 'Show help') do
|
160
|
+
puts o
|
161
|
+
exit 0
|
162
|
+
end
|
163
|
+
end
|
164
|
+
op.parse!(argv)
|
165
|
+
@cmd_args = argv
|
166
|
+
end
|
167
|
+
|
168
|
+
def run_subcommand(cmd, args)
|
169
|
+
model = CoverageModel.new(root: @root, resultset: @resultset, staleness: @stale_mode)
|
170
|
+
case cmd
|
171
|
+
when 'list' then handle_list(model)
|
172
|
+
when 'summary' then handle_summary(model, args)
|
173
|
+
when 'raw' then handle_raw(model, args)
|
174
|
+
when 'uncovered' then handle_uncovered(model, args)
|
175
|
+
when 'detailed' then handle_detailed(model, args)
|
176
|
+
when 'version' then handle_version
|
177
|
+
else raise UsageError.for_subcommand('list | summary <path> | raw <path> | uncovered <path> | detailed <path> | version')
|
178
|
+
end
|
179
|
+
rescue SimpleCovMcp::Error => e
|
180
|
+
handle_user_facing_error(e)
|
181
|
+
rescue => e
|
182
|
+
@error_handler.handle_error(e, context: "subcommand '#{cmd}'")
|
183
|
+
end
|
184
|
+
|
185
|
+
|
186
|
+
def format_detailed_rows(rows)
|
187
|
+
# Simple aligned columns: line, hits, covered
|
188
|
+
out = []
|
189
|
+
out << sprintf('%6s %6s %7s', 'Line', 'Hits', 'Covered')
|
190
|
+
out << sprintf('%6s %6s %7s', '-----', '----', '-------')
|
191
|
+
rows.each do |r|
|
192
|
+
out << sprintf('%6d %6d %7s', r['line'], r['hits'], r['covered'] ? 'yes' : 'no')
|
193
|
+
end
|
194
|
+
out.join("\n")
|
195
|
+
end
|
196
|
+
|
197
|
+
def handle_list(model)
|
198
|
+
show_default_report(sort_order: @sort_order)
|
199
|
+
end
|
200
|
+
|
201
|
+
def handle_version
|
202
|
+
if @json
|
203
|
+
puts JSON.pretty_generate({ version: SimpleCovMcp::VERSION })
|
204
|
+
else
|
205
|
+
puts "SimpleCovMcp version #{SimpleCovMcp::VERSION}"
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
def handle_summary(model, args)
|
210
|
+
handle_with_path(args, 'summary') do |path|
|
211
|
+
data = model.summary_for(path)
|
212
|
+
if @source_mode && @json
|
213
|
+
data = relativize_file(data)
|
214
|
+
src = build_source_payload(model, path)
|
215
|
+
data['source'] = src
|
216
|
+
break puts(JSON.pretty_generate(data))
|
217
|
+
end
|
218
|
+
break if maybe_output_json(relativize_file(data))
|
219
|
+
rel = rel_path(data['file'])
|
220
|
+
s = data['summary']
|
221
|
+
printf '%8.2f%% %6d/%-6d %s\n', s['pct'], s['covered'], s['total'], rel
|
222
|
+
print_source_for(model, path) if @source_mode
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
def handle_raw(model, args)
|
227
|
+
handle_with_path(args, 'raw') do |path|
|
228
|
+
data = model.raw_for(path)
|
229
|
+
break if maybe_output_json(relativize_file(data))
|
230
|
+
rel = rel_path(data['file'])
|
231
|
+
puts "File: #{rel}"
|
232
|
+
puts data['lines'].inspect
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
def handle_uncovered(model, args)
|
237
|
+
handle_with_path(args, 'uncovered') do |path|
|
238
|
+
data = model.uncovered_for(path)
|
239
|
+
if @source_mode && @json
|
240
|
+
data = relativize_file(data)
|
241
|
+
src = build_source_payload(model, path)
|
242
|
+
data['source'] = src
|
243
|
+
break puts(JSON.pretty_generate(data))
|
244
|
+
end
|
245
|
+
break if maybe_output_json(relativize_file(data))
|
246
|
+
rel = rel_path(data['file'])
|
247
|
+
puts "File: #{rel}"
|
248
|
+
puts "Uncovered lines: #{data['uncovered'].join(', ')}"
|
249
|
+
s = data['summary']
|
250
|
+
printf 'Summary: %8.2f%% %6d/%-6d\n', s['pct'], s['covered'], s['total']
|
251
|
+
print_source_for(model, path) if @source_mode
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
255
|
+
def handle_detailed(model, args)
|
256
|
+
handle_with_path(args, 'detailed') do |path|
|
257
|
+
data = model.detailed_for(path)
|
258
|
+
if @source_mode && @json
|
259
|
+
data = relativize_file(data)
|
260
|
+
src = build_source_payload(model, path)
|
261
|
+
data['source'] = src
|
262
|
+
break puts(JSON.pretty_generate(data))
|
263
|
+
end
|
264
|
+
break if maybe_output_json(relativize_file(data))
|
265
|
+
rel = rel_path(data['file'])
|
266
|
+
puts "File: #{rel}"
|
267
|
+
puts format_detailed_rows(data['lines'])
|
268
|
+
print_source_for(model, path) if @source_mode
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
def handle_with_path(args, name)
|
273
|
+
path = args.shift or raise UsageError.for_subcommand("#{name} <path>")
|
274
|
+
yield(path)
|
275
|
+
rescue Errno::ENOENT => e
|
276
|
+
raise FileNotFoundError.new("File not found: #{path}")
|
277
|
+
rescue Errno::EACCES => e
|
278
|
+
raise FilePermissionError.new("Permission denied: #{path}")
|
279
|
+
end
|
280
|
+
|
281
|
+
def rel_path(abs)
|
282
|
+
rel_to_root(abs)
|
283
|
+
end
|
284
|
+
|
285
|
+
def maybe_output_json(obj)
|
286
|
+
return false unless @json
|
287
|
+
puts JSON.pretty_generate(obj)
|
288
|
+
true
|
289
|
+
end
|
290
|
+
|
291
|
+
def print_source_for(model, path)
|
292
|
+
raw = model.raw_for(path)
|
293
|
+
abs = raw['file']
|
294
|
+
lines_cov = raw['lines']
|
295
|
+
src = File.file?(abs) ? File.readlines(abs, chomp: true) : nil
|
296
|
+
unless src
|
297
|
+
puts '[source not available]'
|
298
|
+
return
|
299
|
+
end
|
300
|
+
begin
|
301
|
+
rows = build_source_rows(src, lines_cov, mode: @source_mode, context: @source_context)
|
302
|
+
puts format_source_rows(rows)
|
303
|
+
rescue StandardError
|
304
|
+
# If any unexpected formatting/indexing error occurs, avoid crashing the CLI
|
305
|
+
# and fall back to a neutral message rather than raising.
|
306
|
+
puts '[source not available]'
|
307
|
+
end
|
308
|
+
end
|
309
|
+
|
310
|
+
def build_source_payload(model, path)
|
311
|
+
raw = model.raw_for(path)
|
312
|
+
abs = raw['file']
|
313
|
+
lines_cov = raw['lines']
|
314
|
+
src = File.file?(abs) ? File.readlines(abs, chomp: true) : nil
|
315
|
+
return nil unless src
|
316
|
+
build_source_rows(src, lines_cov, mode: @source_mode, context: @source_context)
|
317
|
+
end
|
318
|
+
|
319
|
+
def build_source_rows(src_lines, cov_lines, mode:, context: 2)
|
320
|
+
# Normalize inputs defensively to avoid type errors in formatting
|
321
|
+
coverage_lines = cov_lines || []
|
322
|
+
context_line_count = context.to_i rescue 2
|
323
|
+
context_line_count = 0 if context_line_count.negative?
|
324
|
+
|
325
|
+
n = src_lines.length
|
326
|
+
include_line = Array.new(n, mode == 'full')
|
327
|
+
if mode == 'uncovered'
|
328
|
+
misses = []
|
329
|
+
coverage_lines.each_with_index do |hits, i|
|
330
|
+
misses << i if !hits.nil? && hits.to_i == 0
|
331
|
+
end
|
332
|
+
misses.each do |i|
|
333
|
+
a = [0, i - context_line_count].max
|
334
|
+
b = [n - 1, i + context_line_count].min
|
335
|
+
(a..b).each { |j| include_line[j] = true }
|
336
|
+
end
|
337
|
+
end
|
338
|
+
out = []
|
339
|
+
src_lines.each_with_index do |code, i|
|
340
|
+
next unless include_line[i]
|
341
|
+
hits = coverage_lines[i]
|
342
|
+
covered = hits.nil? ? nil : hits.to_i > 0
|
343
|
+
out << { line: i + 1, code: code, hits: hits, covered: covered }
|
344
|
+
end
|
345
|
+
out
|
346
|
+
end
|
347
|
+
|
348
|
+
def format_source_rows(rows)
|
349
|
+
marker = ->(covered, hits) do
|
350
|
+
case covered
|
351
|
+
when true then colorize('✓', :green)
|
352
|
+
when false then colorize('·', :red)
|
353
|
+
else colorize(' ', :dim)
|
354
|
+
end
|
355
|
+
end
|
356
|
+
lines = []
|
357
|
+
lines << sprintf('%6s %2s | %s', 'Line', ' ', 'Source')
|
358
|
+
lines << sprintf('%6s %2s-+-%s', '------', '--', '-' * 60)
|
359
|
+
rows.each do |r|
|
360
|
+
m = marker.call(r['covered'], r['hits'])
|
361
|
+
lines << sprintf('%6d %2s | %s', r['line'], m, r['code'])
|
362
|
+
end
|
363
|
+
lines.join("\n")
|
364
|
+
end
|
365
|
+
|
366
|
+
def colorize(text, color)
|
367
|
+
return text unless @color
|
368
|
+
codes = { green: 32, red: 31, dim: 2 }
|
369
|
+
code = codes[color] || 0
|
370
|
+
"\e[#{code}m#{text}\e[0m"
|
371
|
+
end
|
372
|
+
|
373
|
+
def rel_to_root(path)
|
374
|
+
Pathname.new(path).relative_path_from(Pathname.new(File.absolute_path(@root))).to_s
|
375
|
+
end
|
376
|
+
|
377
|
+
def relativize_file(h)
|
378
|
+
return h unless h.is_a?(Hash) && h['file']
|
379
|
+
dup = h.dup
|
380
|
+
dup['file'] = rel_to_root(dup['file'])
|
381
|
+
dup
|
382
|
+
end
|
383
|
+
|
384
|
+
|
385
|
+
def handle_user_facing_error(error)
|
386
|
+
warn error.user_friendly_message
|
387
|
+
exit 1
|
388
|
+
end
|
389
|
+
end
|
390
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'errors'
|
4
|
+
|
5
|
+
module SimpleCovMcp
|
6
|
+
# Handles error reporting and logging with configurable behavior
|
7
|
+
class ErrorHandler
|
8
|
+
attr_accessor :log_errors, :show_stack_traces, :logger
|
9
|
+
|
10
|
+
def initialize(log_errors: true, show_stack_traces: false, logger: nil)
|
11
|
+
@log_errors = log_errors
|
12
|
+
@show_stack_traces = show_stack_traces
|
13
|
+
@logger = logger
|
14
|
+
end
|
15
|
+
|
16
|
+
# Handle an error with appropriate logging and re-raising behavior
|
17
|
+
def handle_error(error, context: nil, reraise: true)
|
18
|
+
log_error(error, context)
|
19
|
+
|
20
|
+
if reraise
|
21
|
+
if error.is_a?(SimpleCovMcp::Error)
|
22
|
+
# Re-raise our custom errors as-is
|
23
|
+
raise error
|
24
|
+
else
|
25
|
+
# Convert standard errors to our custom errors for better UX
|
26
|
+
converted_error = convert_standard_error(error)
|
27
|
+
raise converted_error
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Convert standard Ruby errors to user-friendly custom errors
|
33
|
+
def convert_standard_error(error)
|
34
|
+
case error
|
35
|
+
when Errno::ENOENT
|
36
|
+
filename = extract_filename(error.message)
|
37
|
+
FileNotFoundError.new("File not found: #{filename}", error)
|
38
|
+
when Errno::EACCES
|
39
|
+
filename = extract_filename(error.message)
|
40
|
+
FilePermissionError.new("Permission denied accessing file: #{filename}", error)
|
41
|
+
when Errno::EISDIR
|
42
|
+
filename = extract_filename(error.message)
|
43
|
+
NotAFileError.new("Expected file but found directory: #{filename}", error)
|
44
|
+
when JSON::ParserError
|
45
|
+
CoverageDataError.new("Invalid coverage data format - JSON parsing failed: #{error.message}", error)
|
46
|
+
when ArgumentError
|
47
|
+
if error.message.include?('wrong number of arguments')
|
48
|
+
UsageError.new("Invalid number of arguments: #{error.message}", error)
|
49
|
+
else
|
50
|
+
ConfigurationError.new("Invalid configuration: #{error.message}", error)
|
51
|
+
end
|
52
|
+
when NoMethodError
|
53
|
+
method_info = extract_method_info(error.message)
|
54
|
+
CoverageDataError.new("Invalid coverage data structure - #{method_info}", error)
|
55
|
+
when RuntimeError, StandardError
|
56
|
+
# Handle string errors from CovUtil and other runtime errors
|
57
|
+
if error.message.include?('No coverage entry found for')
|
58
|
+
# Extract the file path from the error message
|
59
|
+
filepath = error.message.match(/No coverage entry found for (.+)$/)&.[](1) || "specified file"
|
60
|
+
FileError.new("No coverage data found for file: #{filepath}", error)
|
61
|
+
elsif error.message.include?('Could not find .resultset.json')
|
62
|
+
# Extract directory info if available
|
63
|
+
dir_info = error.message.match(/under (.+?)(?:;|$)/)&.[](1) || "project directory"
|
64
|
+
CoverageDataError.new("Coverage data not found in #{dir_info} - please run your tests first", error)
|
65
|
+
elsif error.message.include?('No .resultset.json found in directory')
|
66
|
+
# Extract directory from error message
|
67
|
+
dir_info = error.message.match(/directory: (.+)$/)&.[](1) || "specified directory"
|
68
|
+
CoverageDataError.new("Coverage data not found in directory: #{dir_info}", error)
|
69
|
+
elsif error.message.include?('Specified resultset not found')
|
70
|
+
# Extract path from error message
|
71
|
+
path_info = error.message.match(/not found: (.+)$/)&.[](1) || "specified path"
|
72
|
+
ResultsetNotFoundError.new("Resultset file not found: #{path_info}", error)
|
73
|
+
else
|
74
|
+
Error.new("An unexpected error occurred: #{error.message}", error)
|
75
|
+
end
|
76
|
+
else
|
77
|
+
Error.new("An unexpected error occurred: #{error.message}", error)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
private
|
82
|
+
|
83
|
+
def log_error(error, context)
|
84
|
+
return unless @log_errors
|
85
|
+
|
86
|
+
message = build_log_message(error, context)
|
87
|
+
if @logger
|
88
|
+
@logger.error(message)
|
89
|
+
else
|
90
|
+
CovUtil.log(message)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def build_log_message(error, context)
|
95
|
+
parts = []
|
96
|
+
parts << "Error#{context ? " in #{context}" : ''}: #{error.class}: #{error.message}"
|
97
|
+
|
98
|
+
if @show_stack_traces && error.backtrace
|
99
|
+
parts << error.backtrace.join("\n")
|
100
|
+
end
|
101
|
+
|
102
|
+
parts.join("\n")
|
103
|
+
end
|
104
|
+
|
105
|
+
def extract_filename(message)
|
106
|
+
# Extract filename from "No such file or directory @ rb_sysopen - filename"
|
107
|
+
match = message.match(/@ \w+ - (.+)$/)
|
108
|
+
match ? match[1] : "unknown file"
|
109
|
+
end
|
110
|
+
|
111
|
+
def extract_method_info(message)
|
112
|
+
# Extract method info from "undefined method `foo' for #<Object:0x...>"
|
113
|
+
if match = message.match(/undefined method `(.+?)' for (.+)$/)
|
114
|
+
method_name = match[1]
|
115
|
+
object_info = match[2].gsub(/#<.*?>/, 'object')
|
116
|
+
"missing method '#{method_name}' on #{object_info}"
|
117
|
+
else
|
118
|
+
message
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
# Global error handler configuration
|
124
|
+
class << self
|
125
|
+
attr_writer :error_handler
|
126
|
+
|
127
|
+
def error_handler
|
128
|
+
@error_handler or raise "Error handler not configured. Use one of: SimpleCovMcp.run, .run_as_library, or set .error_handler= explicitly"
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SimpleCovMcp
|
4
|
+
module ErrorHandlerFactory
|
5
|
+
# Error handler for CLI usage
|
6
|
+
# - Logs errors for debugging
|
7
|
+
# - Shows stack traces only in debug mode
|
8
|
+
# - Suitable for user-facing command line interface
|
9
|
+
def self.for_cli
|
10
|
+
ErrorHandler.new(
|
11
|
+
log_errors: true,
|
12
|
+
show_stack_traces: ENV['SIMPLECOV_MCP_DEBUG'] == '1'
|
13
|
+
)
|
14
|
+
end
|
15
|
+
|
16
|
+
# Error handler for library usage
|
17
|
+
# - No logging by default (avoids side effects in consuming applications)
|
18
|
+
# - No stack traces (libraries should let consumers handle error display)
|
19
|
+
# - Suitable for embedding in other applications
|
20
|
+
def self.for_library
|
21
|
+
ErrorHandler.new(
|
22
|
+
log_errors: false,
|
23
|
+
show_stack_traces: false
|
24
|
+
)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Error handler for MCP server usage
|
28
|
+
# - Logs errors for server debugging
|
29
|
+
# - Shows stack traces only in debug mode
|
30
|
+
# - Suitable for long-running server processes
|
31
|
+
def self.for_mcp_server
|
32
|
+
ErrorHandler.new(
|
33
|
+
log_errors: true,
|
34
|
+
show_stack_traces: ENV['SIMPLECOV_MCP_DEBUG'] == '1'
|
35
|
+
)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|