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,176 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SimpleCovMcp
|
4
|
+
# Base error class for all SimpleCov MCP errors
|
5
|
+
class Error < StandardError
|
6
|
+
attr_reader :original_error
|
7
|
+
|
8
|
+
def initialize(message = nil, original_error = nil)
|
9
|
+
@original_error = original_error
|
10
|
+
super(message)
|
11
|
+
end
|
12
|
+
|
13
|
+
def user_friendly_message
|
14
|
+
message
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# Configuration or setup related errors
|
19
|
+
class ConfigurationError < Error
|
20
|
+
def user_friendly_message
|
21
|
+
"Configuration error: #{message}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# File or path related errors
|
26
|
+
class FileError < Error
|
27
|
+
def user_friendly_message
|
28
|
+
"File error: #{message}"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# More specific file errors
|
33
|
+
class FileNotFoundError < FileError; end
|
34
|
+
class FilePermissionError < FileError; end
|
35
|
+
class NotAFileError < FileError; end
|
36
|
+
class ResultsetNotFoundError < FileError; end
|
37
|
+
|
38
|
+
# Coverage data related errors
|
39
|
+
class CoverageDataError < Error
|
40
|
+
def user_friendly_message
|
41
|
+
"Coverage data error: #{message}"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Coverage data is present but appears stale compared to source files
|
46
|
+
class CoverageDataStaleError < CoverageDataError
|
47
|
+
attr_reader :file_path, :file_mtime, :cov_timestamp, :src_len, :cov_len, :resultset_path
|
48
|
+
|
49
|
+
def initialize(message = nil, original_error = nil, file_path: nil, file_mtime: nil, cov_timestamp: nil, src_len: nil, cov_len: nil, resultset_path: nil)
|
50
|
+
super(message, original_error)
|
51
|
+
@file_path = file_path
|
52
|
+
@file_mtime = file_mtime
|
53
|
+
@cov_timestamp = cov_timestamp
|
54
|
+
@src_len = src_len
|
55
|
+
@cov_len = cov_len
|
56
|
+
@resultset_path = resultset_path
|
57
|
+
end
|
58
|
+
|
59
|
+
def user_friendly_message
|
60
|
+
base = "Coverage data stale: #{message || default_message}"
|
61
|
+
base + build_details
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def default_message
|
67
|
+
fp = file_path || 'file'
|
68
|
+
"Coverage data appears stale for #{fp}"
|
69
|
+
end
|
70
|
+
|
71
|
+
def build_details
|
72
|
+
file_utc, file_local = format_time_both(@file_mtime)
|
73
|
+
cov_utc, cov_local = format_epoch_both(@cov_timestamp)
|
74
|
+
delta_str = format_delta_seconds(@file_mtime, @cov_timestamp)
|
75
|
+
details = []
|
76
|
+
details << "\nFile - time: #{file_utc || 'not found'} (local #{file_local || 'n/a'}), lines: #{@src_len}"
|
77
|
+
details << "\nCoverage - time: #{cov_utc || 'not found'} (local #{cov_local || 'n/a'}), lines: #{@cov_len}"
|
78
|
+
details << "\nDelta - file is #{delta_str} newer than coverage" if delta_str
|
79
|
+
details << "\nResultset - #{@resultset_path}" if @resultset_path
|
80
|
+
details.join
|
81
|
+
end
|
82
|
+
|
83
|
+
def format_epoch_both(epoch_seconds)
|
84
|
+
return [nil, nil] unless epoch_seconds
|
85
|
+
t = Time.at(epoch_seconds.to_i)
|
86
|
+
[t.utc.iso8601, t.getlocal.iso8601]
|
87
|
+
rescue StandardError
|
88
|
+
[epoch_seconds.to_s, epoch_seconds.to_s]
|
89
|
+
end
|
90
|
+
|
91
|
+
def format_time_both(time)
|
92
|
+
return [nil, nil] unless time
|
93
|
+
t = time.is_a?(Time) ? time : Time.parse(time.to_s)
|
94
|
+
[t.utc.iso8601, t.getlocal.iso8601]
|
95
|
+
rescue StandardError
|
96
|
+
[time.to_s, time.to_s]
|
97
|
+
end
|
98
|
+
|
99
|
+
def format_delta_seconds(file_mtime, cov_timestamp)
|
100
|
+
return nil unless file_mtime && cov_timestamp
|
101
|
+
seconds = file_mtime.to_i - cov_timestamp.to_i
|
102
|
+
sign = seconds >= 0 ? '+' : '-'
|
103
|
+
"#{sign}#{seconds.abs}s"
|
104
|
+
rescue StandardError
|
105
|
+
nil
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
# Project-level stale coverage (global) — coverage timestamp older than
|
110
|
+
# one or more source files, or new tracked files missing from coverage.
|
111
|
+
class CoverageDataProjectStaleError < CoverageDataError
|
112
|
+
attr_reader :cov_timestamp, :newer_files, :missing_files, :deleted_files, :resultset_path
|
113
|
+
|
114
|
+
def initialize(message = nil, original_error = nil, cov_timestamp: nil, newer_files: [], missing_files: [], deleted_files: [], resultset_path: nil)
|
115
|
+
super(message, original_error)
|
116
|
+
@cov_timestamp = cov_timestamp
|
117
|
+
@newer_files = Array(newer_files)
|
118
|
+
@missing_files = Array(missing_files)
|
119
|
+
@deleted_files = Array(deleted_files)
|
120
|
+
@resultset_path = resultset_path
|
121
|
+
end
|
122
|
+
|
123
|
+
def user_friendly_message
|
124
|
+
base = "Coverage data stale (project): #{message || default_message}"
|
125
|
+
base + build_details
|
126
|
+
end
|
127
|
+
|
128
|
+
private
|
129
|
+
|
130
|
+
def default_message
|
131
|
+
'Coverage data appears stale for project'
|
132
|
+
end
|
133
|
+
|
134
|
+
def build_details
|
135
|
+
cov_utc, cov_local = format_epoch_both(@cov_timestamp)
|
136
|
+
parts = []
|
137
|
+
parts << "\nCoverage - time: #{cov_utc || 'not found'} (local #{cov_local || 'n/a'})"
|
138
|
+
unless @newer_files.empty?
|
139
|
+
parts << "\nNewer files (#{@newer_files.size}):"
|
140
|
+
parts.concat(@newer_files.first(10).map { |f| " - #{f}" })
|
141
|
+
parts << " ..." if @newer_files.size > 10
|
142
|
+
end
|
143
|
+
unless @missing_files.empty?
|
144
|
+
parts << "\nMissing files (new in project, not in coverage, #{@missing_files.size}):"
|
145
|
+
parts.concat(@missing_files.first(10).map { |f| " - #{f}" })
|
146
|
+
parts << " ..." if @missing_files.size > 10
|
147
|
+
end
|
148
|
+
unless @deleted_files.empty?
|
149
|
+
parts << "\nCoverage-only files (deleted or moved in project, #{@deleted_files.size}):"
|
150
|
+
parts.concat(@deleted_files.first(10).map { |f| " - #{f}" })
|
151
|
+
parts << " ..." if @deleted_files.size > 10
|
152
|
+
end
|
153
|
+
parts << "\nResultset - #{@resultset_path}" if @resultset_path
|
154
|
+
parts.join
|
155
|
+
end
|
156
|
+
|
157
|
+
def format_epoch_both(epoch_seconds)
|
158
|
+
return [nil, nil] unless epoch_seconds
|
159
|
+
t = Time.at(epoch_seconds.to_i)
|
160
|
+
[t.utc.iso8601, t.getlocal.iso8601]
|
161
|
+
rescue StandardError
|
162
|
+
[epoch_seconds.to_s, epoch_seconds.to_s]
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
# Command line usage errors
|
167
|
+
class UsageError < Error
|
168
|
+
def self.for_subcommand(usage_fragment)
|
169
|
+
new("Usage: simplecov-mcp #{usage_fragment}")
|
170
|
+
end
|
171
|
+
|
172
|
+
def user_friendly_message
|
173
|
+
message
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SimpleCovMcp
|
4
|
+
class MCPServer
|
5
|
+
def initialize
|
6
|
+
# Configure error handling for MCP server mode using the factory
|
7
|
+
SimpleCovMcp.error_handler = ErrorHandlerFactory.for_mcp_server
|
8
|
+
end
|
9
|
+
|
10
|
+
def run
|
11
|
+
tools = [
|
12
|
+
Tools::AllFilesCoverageTool,
|
13
|
+
Tools::CoverageDetailedTool,
|
14
|
+
Tools::CoverageRawTool,
|
15
|
+
Tools::CoverageSummaryTool,
|
16
|
+
Tools::UncoveredLinesTool,
|
17
|
+
Tools::CoverageTableTool,
|
18
|
+
Tools::HelpTool,
|
19
|
+
Tools::VersionTool
|
20
|
+
]
|
21
|
+
|
22
|
+
server = ::MCP::Server.new(
|
23
|
+
name: 'simplecov-mcp',
|
24
|
+
version: SimpleCovMcp::VERSION,
|
25
|
+
tools: tools
|
26
|
+
)
|
27
|
+
::MCP::Server::Transports::StdioTransport.new(server).open
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'util'
|
4
|
+
require_relative 'errors'
|
5
|
+
require_relative 'staleness_checker'
|
6
|
+
|
7
|
+
module SimpleCovMcp
|
8
|
+
class CoverageModel
|
9
|
+
# Create a CoverageModel
|
10
|
+
#
|
11
|
+
# Params:
|
12
|
+
# - root: project root directory (default '.')
|
13
|
+
# - resultset: path or directory to .resultset.json
|
14
|
+
# - staleness: 'off' or 'error' (default 'off'). When 'error', raises
|
15
|
+
# stale errors if sources are newer than coverage or line counts mismatch.
|
16
|
+
# - tracked_globs: only used for all_files project-level staleness.
|
17
|
+
def initialize(root: '.', resultset: nil, staleness: 'off', tracked_globs: nil)
|
18
|
+
@root = File.absolute_path(root || '.')
|
19
|
+
@resultset = resultset
|
20
|
+
@checker = StalenessChecker.new(root: @root, resultset: @resultset, mode: staleness, tracked_globs: tracked_globs)
|
21
|
+
begin
|
22
|
+
@cov = CovUtil.load_latest_coverage(@root, resultset: resultset)
|
23
|
+
rescue Errno::ENOENT => e
|
24
|
+
raise FileError.new("Coverage data not found at #{resultset || @root}")
|
25
|
+
rescue JSON::ParserError => e
|
26
|
+
raise CoverageDataError.new("Invalid coverage data format")
|
27
|
+
rescue => e
|
28
|
+
raise CoverageDataError.new("Failed to load coverage data: #{e.message}")
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Returns { 'file' => <absolute_path>, 'lines' => [hits|nil,...] }
|
33
|
+
def raw_for(path)
|
34
|
+
file_abs, coverage_lines = resolve(path)
|
35
|
+
{ 'file' => file_abs, 'lines' => coverage_lines }
|
36
|
+
end
|
37
|
+
|
38
|
+
# Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, 'pct'=>} }
|
39
|
+
def summary_for(path)
|
40
|
+
file_abs, coverage_lines = resolve(path)
|
41
|
+
{ 'file' => file_abs, 'summary' => CovUtil.summary(coverage_lines) }
|
42
|
+
end
|
43
|
+
|
44
|
+
# Returns { 'file' => <absolute_path>, 'uncovered' => [line,...], 'summary' => {...} }
|
45
|
+
def uncovered_for(path)
|
46
|
+
file_abs, coverage_lines = resolve(path)
|
47
|
+
{ 'file' => file_abs, 'uncovered' => CovUtil.uncovered(coverage_lines), 'summary' => CovUtil.summary(coverage_lines) }
|
48
|
+
end
|
49
|
+
|
50
|
+
# Returns { 'file' => <absolute_path>, 'lines' => [{'line'=>,'hits'=>,'covered'=>},...], 'summary' => {...} }
|
51
|
+
def detailed_for(path)
|
52
|
+
file_abs, coverage_lines = resolve(path)
|
53
|
+
{ 'file' => file_abs, 'lines' => CovUtil.detailed(coverage_lines), 'summary' => CovUtil.summary(coverage_lines) }
|
54
|
+
end
|
55
|
+
|
56
|
+
# Returns [ { 'file' =>, 'covered' =>, 'total' =>, 'percentage' =>, 'stale' => }, ... ]
|
57
|
+
def all_files(sort_order: :ascending, check_stale: !@checker.off?, tracked_globs: nil)
|
58
|
+
stale_checker = StalenessChecker.new(root: @root, resultset: @resultset, mode: 'off', tracked_globs: tracked_globs)
|
59
|
+
rows = @cov.map do |abs_path, data|
|
60
|
+
next unless data['lines'].is_a?(Array)
|
61
|
+
s = CovUtil.summary(data['lines'])
|
62
|
+
stale = stale_checker.stale_for_file?(abs_path, data['lines'])
|
63
|
+
{ 'file' => abs_path, 'covered' => s['covered'], 'total' => s['total'], 'percentage' => s['pct'], 'stale' => stale }
|
64
|
+
end.compact
|
65
|
+
|
66
|
+
if check_stale
|
67
|
+
StalenessChecker.new(root: @root, resultset: @resultset, mode: 'error', tracked_globs: tracked_globs)
|
68
|
+
.check_project!(@cov)
|
69
|
+
end
|
70
|
+
|
71
|
+
rows.sort! do |a, b|
|
72
|
+
pct_cmp = (sort_order.to_s == 'descending') ? (b['percentage'] <=> a['percentage']) : (a['percentage'] <=> b['percentage'])
|
73
|
+
pct_cmp == 0 ? (a['file'] <=> b['file']) : pct_cmp
|
74
|
+
end
|
75
|
+
rows
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def resolve(path)
|
81
|
+
file_abs = File.absolute_path(path, @root)
|
82
|
+
coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
|
83
|
+
@checker.check_file!(file_abs, coverage_lines) unless @checker.off?
|
84
|
+
if coverage_lines.nil?
|
85
|
+
raise FileError.new("No coverage data found for file: #{path}")
|
86
|
+
end
|
87
|
+
[file_abs, coverage_lines]
|
88
|
+
rescue Errno::ENOENT => e
|
89
|
+
raise FileNotFoundError.new("File not found: #{path}")
|
90
|
+
end
|
91
|
+
|
92
|
+
# staleness handled by StalenessChecker
|
93
|
+
|
94
|
+
def check_all_files_staleness!(cov_timestamp, tracked_globs: nil)
|
95
|
+
# handled by StalenessChecker
|
96
|
+
end
|
97
|
+
|
98
|
+
def rel_to_root(path)
|
99
|
+
Pathname.new(path).relative_path_from(Pathname.new(File.absolute_path(@root))).to_s
|
100
|
+
end
|
101
|
+
|
102
|
+
# Detailed stale message construction moved to CoverageDataStaleError
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'time'
|
4
|
+
|
5
|
+
module SimpleCovMcp
|
6
|
+
# Lightweight service object to check staleness of coverage vs. sources
|
7
|
+
class StalenessChecker
|
8
|
+
MODES = %w[off error].freeze
|
9
|
+
|
10
|
+
def initialize(root:, resultset:, mode: 'off', tracked_globs: nil)
|
11
|
+
@root = File.absolute_path(root || '.')
|
12
|
+
@resultset = resultset
|
13
|
+
@mode = (mode || 'off').to_s
|
14
|
+
@tracked_globs = tracked_globs
|
15
|
+
@cov_timestamp = nil
|
16
|
+
@resultset_path = nil
|
17
|
+
end
|
18
|
+
|
19
|
+
def off?
|
20
|
+
@mode == 'off'
|
21
|
+
end
|
22
|
+
|
23
|
+
# Raise CoverageDataStaleError if stale (only in error mode)
|
24
|
+
def check_file!(file_abs, coverage_lines)
|
25
|
+
return if off?
|
26
|
+
ts = coverage_timestamp
|
27
|
+
fm = File.mtime(file_abs) if File.file?(file_abs)
|
28
|
+
cov_len = coverage_lines.respond_to?(:length) ? coverage_lines.length : 0
|
29
|
+
src_len = safe_count_lines(file_abs)
|
30
|
+
if (fm && fm.to_i > ts.to_i) || (cov_len.positive? && src_len != cov_len)
|
31
|
+
raise CoverageDataStaleError.new(
|
32
|
+
nil,
|
33
|
+
nil,
|
34
|
+
file_path: rel(file_abs),
|
35
|
+
file_mtime: fm,
|
36
|
+
cov_timestamp: ts,
|
37
|
+
src_len: src_len,
|
38
|
+
cov_len: cov_len,
|
39
|
+
resultset_path: resultset_path
|
40
|
+
)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Compute whether a specific file appears stale relative to coverage.
|
45
|
+
# Ignores mode and never raises; returns true when:
|
46
|
+
# - the file is missing/deleted, or
|
47
|
+
# - the file mtime is newer than the coverage timestamp, or
|
48
|
+
# - the source line count differs from the coverage lines array length (when present).
|
49
|
+
def stale_for_file?(file_abs, coverage_lines)
|
50
|
+
ts = coverage_timestamp
|
51
|
+
return true unless File.file?(file_abs)
|
52
|
+
|
53
|
+
fm = File.mtime(file_abs)
|
54
|
+
cov_len = coverage_lines.respond_to?(:length) ? coverage_lines.length : 0
|
55
|
+
src_len = safe_count_lines(file_abs)
|
56
|
+
(fm && fm.to_i > ts.to_i) || (cov_len.positive? && src_len != cov_len)
|
57
|
+
rescue StandardError
|
58
|
+
# Be conservative: if we cannot determine, mark as stale
|
59
|
+
true
|
60
|
+
end
|
61
|
+
|
62
|
+
# Raise CoverageDataProjectStaleError if any covered file is newer or if
|
63
|
+
# tracked files are missing from coverage, or coverage includes deleted files.
|
64
|
+
def check_project!(coverage_map)
|
65
|
+
return if off?
|
66
|
+
ts = coverage_timestamp
|
67
|
+
newer = []
|
68
|
+
deleted = []
|
69
|
+
coverage_files = coverage_map.keys
|
70
|
+
coverage_files.each do |abs|
|
71
|
+
if File.file?(abs)
|
72
|
+
newer << rel(abs) if File.mtime(abs).to_i > ts.to_i
|
73
|
+
else
|
74
|
+
deleted << rel(abs)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
missing = []
|
79
|
+
if @tracked_globs && !Array(@tracked_globs).empty?
|
80
|
+
patterns = Array(@tracked_globs).map { |g| File.absolute_path(g, @root) }
|
81
|
+
tracked = patterns.flat_map { |p| Dir.glob(p, File::FNM_EXTGLOB | File::FNM_PATHNAME) }
|
82
|
+
.select { |p| File.file?(p) }
|
83
|
+
covered_set = coverage_files.to_set rescue coverage_files
|
84
|
+
tracked.each do |abs|
|
85
|
+
missing << rel(abs) unless covered_set.include?(abs)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
if !newer.empty? || !missing.empty? || !deleted.empty?
|
90
|
+
raise CoverageDataProjectStaleError.new(
|
91
|
+
nil,
|
92
|
+
nil,
|
93
|
+
cov_timestamp: ts,
|
94
|
+
newer_files: newer,
|
95
|
+
missing_files: missing,
|
96
|
+
deleted_files: deleted,
|
97
|
+
resultset_path: resultset_path
|
98
|
+
)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
private
|
103
|
+
|
104
|
+
def coverage_timestamp
|
105
|
+
@cov_timestamp ||= CovUtil.latest_timestamp(@root, resultset: @resultset)
|
106
|
+
end
|
107
|
+
|
108
|
+
def resultset_path
|
109
|
+
@resultset_path ||= CovUtil.find_resultset(@root, resultset: @resultset)
|
110
|
+
rescue StandardError
|
111
|
+
nil
|
112
|
+
end
|
113
|
+
|
114
|
+
def safe_count_lines(path)
|
115
|
+
return 0 unless File.file?(path)
|
116
|
+
File.foreach(path).count
|
117
|
+
rescue StandardError
|
118
|
+
0
|
119
|
+
end
|
120
|
+
|
121
|
+
def rel(path)
|
122
|
+
Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../model'
|
4
|
+
require_relative '../base_tool'
|
5
|
+
|
6
|
+
module SimpleCovMcp
|
7
|
+
module Tools
|
8
|
+
class AllFilesCoverageTool < BaseTool
|
9
|
+
description <<~DESC
|
10
|
+
Use this when the user wants coverage percentages for every tracked file in the project.
|
11
|
+
Do not use this for single-file stats; prefer coverage.summary or coverage.uncovered_lines for that.
|
12
|
+
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
|
+
Examples: "List files with the lowest coverage"; "Show repo coverage sorted descending".
|
15
|
+
DESC
|
16
|
+
input_schema(
|
17
|
+
type: 'object',
|
18
|
+
additionalProperties: false,
|
19
|
+
properties: {
|
20
|
+
root: {
|
21
|
+
type: 'string',
|
22
|
+
description: 'Project root used to resolve relative inputs.',
|
23
|
+
default: '.'
|
24
|
+
},
|
25
|
+
resultset: {
|
26
|
+
type: 'string',
|
27
|
+
description: 'Path to the SimpleCov .resultset.json file.'
|
28
|
+
},
|
29
|
+
sort_order: {
|
30
|
+
type: 'string',
|
31
|
+
description: "Sort order for coverage percentages. 'ascending' highlights the riskiest files first.",
|
32
|
+
default: 'ascending',
|
33
|
+
enum: ['ascending', 'descending']
|
34
|
+
},
|
35
|
+
stale: {
|
36
|
+
type: 'string',
|
37
|
+
description: "How to handle missing/outdated coverage data. 'off' skips checks; 'error' raises.",
|
38
|
+
enum: ['off', 'error'],
|
39
|
+
default: 'off'
|
40
|
+
},
|
41
|
+
tracked_globs: {
|
42
|
+
type: 'array',
|
43
|
+
description: 'Glob patterns for files that should exist in the coverage report (helps flag new files).',
|
44
|
+
items: { type: 'string' }
|
45
|
+
}
|
46
|
+
}
|
47
|
+
)
|
48
|
+
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
|
+
::MCP::Tool::Response.new([
|
57
|
+
{ 'type' => 'text', 'text' => JSON.generate(payload) }
|
58
|
+
])
|
59
|
+
rescue => e
|
60
|
+
handle_mcp_error(e, 'AllFilesCoverageTool')
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../base_tool'
|
4
|
+
require_relative '../model'
|
5
|
+
|
6
|
+
module SimpleCovMcp
|
7
|
+
module Tools
|
8
|
+
class CoverageDetailedTool < BaseTool
|
9
|
+
description <<~DESC
|
10
|
+
Use this when the user needs per-line coverage data for a single file.
|
11
|
+
Do not use this for high-level counts; coverage.summary is cheaper for aggregate numbers.
|
12
|
+
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
|
+
Example: "Show detailed coverage for lib/simple_cov_mcp/model.rb".
|
15
|
+
DESC
|
16
|
+
input_schema(**input_schema_def)
|
17
|
+
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
|
+
::MCP::Tool::Response.new([{ type: 'text', text: JSON.pretty_generate(data) }])
|
23
|
+
rescue => e
|
24
|
+
handle_mcp_error(e, 'CoverageDetailedTool')
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../base_tool'
|
4
|
+
require_relative '../model'
|
5
|
+
|
6
|
+
module SimpleCovMcp
|
7
|
+
module Tools
|
8
|
+
class CoverageRawTool < BaseTool
|
9
|
+
description <<~DESC
|
10
|
+
Use this when you need the raw SimpleCov `lines` array for a file exactly as stored on disk.
|
11
|
+
Do not use this for human-friendly explanations; choose coverage.detailed or coverage.summary instead.
|
12
|
+
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
|
+
Example: "Fetch the raw coverage array for spec/support/foo_helper.rb".
|
15
|
+
DESC
|
16
|
+
input_schema(**input_schema_def)
|
17
|
+
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
|
+
::MCP::Tool::Response.new([{ type: 'text', text: JSON.pretty_generate(data) }])
|
23
|
+
rescue => e
|
24
|
+
handle_mcp_error(e, 'CoverageRawTool')
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../base_tool'
|
4
|
+
require_relative '../model'
|
5
|
+
|
6
|
+
module SimpleCovMcp
|
7
|
+
module Tools
|
8
|
+
class CoverageSummaryTool < BaseTool
|
9
|
+
description <<~DESC
|
10
|
+
Use this when the user asks for the covered/total line counts and percentage for a specific file.
|
11
|
+
Do not use this for multi-file reports; coverage.all_files or coverage.table handle those.
|
12
|
+
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
|
+
Examples: "What is the coverage for lib/simple_cov_mcp/tools/all_files_coverage_tool.rb?".
|
15
|
+
DESC
|
16
|
+
input_schema(**input_schema_def)
|
17
|
+
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
|
+
::MCP::Tool::Response.new([{ type: 'text', text: JSON.pretty_generate(data) }])
|
23
|
+
rescue => e
|
24
|
+
handle_mcp_error(e, 'CoverageSummaryTool')
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'stringio'
|
4
|
+
require_relative '../cli'
|
5
|
+
require_relative '../base_tool'
|
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. 'off' skips checks; 'error' raises.",
|
39
|
+
enum: ['off', 'error'],
|
40
|
+
default: 'off'
|
41
|
+
}
|
42
|
+
}
|
43
|
+
)
|
44
|
+
|
45
|
+
class << self
|
46
|
+
def call(root: '.', resultset: nil, sort_order: 'ascending', stale: 'off', server_context:)
|
47
|
+
# Capture the output of the CLI's table report while honoring CLI options
|
48
|
+
output = StringIO.new
|
49
|
+
cli = CoverageCLI.new
|
50
|
+
cli.instance_variable_set(:@root, root || '.')
|
51
|
+
cli.instance_variable_set(:@resultset, resultset)
|
52
|
+
cli.instance_variable_set(:@stale_mode, (stale || 'off').to_s)
|
53
|
+
cli.show_default_report(sort_order: sort_order.to_sym, output: output)
|
54
|
+
::MCP::Tool::Response.new([{ type: 'text', text: output.string }])
|
55
|
+
rescue => e
|
56
|
+
handle_mcp_error(e, 'CoverageTableTool')
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|