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,136 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../base_tool'
|
4
|
+
|
5
|
+
module SimpleCovMcp
|
6
|
+
module Tools
|
7
|
+
class HelpTool < BaseTool
|
8
|
+
description <<~DESC
|
9
|
+
Use this when you are unsure which simplecov-mcp tool fits the user’s coverage request.
|
10
|
+
Do not use this once you know the correct tool; call that tool directly.
|
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.
|
13
|
+
Example: "Which tool shows uncovered lines?".
|
14
|
+
DESC
|
15
|
+
|
16
|
+
input_schema(
|
17
|
+
type: 'object',
|
18
|
+
additionalProperties: false,
|
19
|
+
properties: {
|
20
|
+
query: {
|
21
|
+
type: 'string',
|
22
|
+
description: 'Optional keywords to filter the help entries (e.g., "uncovered", "summary").'
|
23
|
+
}
|
24
|
+
}
|
25
|
+
)
|
26
|
+
|
27
|
+
TOOL_GUIDE = [
|
28
|
+
{
|
29
|
+
tool: CoverageSummaryTool,
|
30
|
+
label: 'Single-file coverage summary',
|
31
|
+
use_when: 'User wants covered/total line counts or percentage for one file.',
|
32
|
+
avoid_when: 'User needs repo-wide stats or specific uncovered lines.',
|
33
|
+
inputs: ['path (required)', 'root/resultset/stale (optional)'],
|
34
|
+
example: 'What is the coverage for lib/simple_cov_mcp/model.rb?'
|
35
|
+
},
|
36
|
+
{
|
37
|
+
tool: UncoveredLinesTool,
|
38
|
+
label: 'Uncovered line numbers',
|
39
|
+
use_when: 'User asks which lines in a file still lack tests.',
|
40
|
+
avoid_when: 'User only wants overall percentages or detailed per-line hit data.',
|
41
|
+
inputs: ['path (required)', 'root/resultset/stale (optional)'],
|
42
|
+
example: 'List uncovered lines for lib/simple_cov_mcp/tools/coverage_summary_tool.rb.'
|
43
|
+
},
|
44
|
+
{
|
45
|
+
tool: CoverageDetailedTool,
|
46
|
+
label: 'Per-line coverage details',
|
47
|
+
use_when: 'User needs per-line hit counts for a file.',
|
48
|
+
avoid_when: 'User only wants totals or uncovered line numbers.',
|
49
|
+
inputs: ['path (required)', 'root/resultset/stale (optional)'],
|
50
|
+
example: 'Show detailed coverage for lib/simple_cov_mcp/util.rb.'
|
51
|
+
},
|
52
|
+
{
|
53
|
+
tool: CoverageRawTool,
|
54
|
+
label: 'Raw SimpleCov lines array',
|
55
|
+
use_when: 'User needs the raw SimpleCov `lines` array for a file.',
|
56
|
+
avoid_when: 'User expects human-friendly summaries or explanations.',
|
57
|
+
inputs: ['path (required)', 'root/resultset/stale (optional)'],
|
58
|
+
example: 'Fetch the raw coverage array for spec/support/helpers.rb.'
|
59
|
+
},
|
60
|
+
{
|
61
|
+
tool: AllFilesCoverageTool,
|
62
|
+
label: 'Repo-wide file coverage',
|
63
|
+
use_when: 'User wants coverage percentages for every tracked file.',
|
64
|
+
avoid_when: 'User asks about a single file.',
|
65
|
+
inputs: ['root/resultset (optional)', 'sort_order', 'stale', 'tracked_globs'],
|
66
|
+
example: 'List files with the lowest coverage.'
|
67
|
+
},
|
68
|
+
{
|
69
|
+
tool: CoverageTableTool,
|
70
|
+
label: 'Formatted coverage table',
|
71
|
+
use_when: 'User wants the plain-text table produced by the CLI.',
|
72
|
+
avoid_when: 'User needs JSON data for automation.',
|
73
|
+
inputs: ['root/resultset (optional)', 'sort_order', 'stale'],
|
74
|
+
example: 'Show me the coverage table sorted descending.'
|
75
|
+
},
|
76
|
+
{
|
77
|
+
tool: VersionTool,
|
78
|
+
label: 'simplecov-mcp version',
|
79
|
+
use_when: 'User needs to confirm the running gem version.',
|
80
|
+
avoid_when: 'User is asking for coverage information.',
|
81
|
+
inputs: ['(no arguments)'],
|
82
|
+
example: 'What version of simplecov-mcp is installed?'
|
83
|
+
}
|
84
|
+
].freeze
|
85
|
+
|
86
|
+
class << self
|
87
|
+
def call(query: nil, server_context:, **_unused)
|
88
|
+
entries = TOOL_GUIDE.map { |guide| format_entry(guide) }
|
89
|
+
entries = filter_entries(entries, query) if query && !query.strip.empty?
|
90
|
+
|
91
|
+
data = { query: query, tools: entries }
|
92
|
+
::MCP::Tool::Response.new([
|
93
|
+
{ 'type' => 'text', 'text' => JSON.generate(data) }
|
94
|
+
])
|
95
|
+
rescue => e
|
96
|
+
handle_mcp_error(e, 'HelpTool')
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
def format_entry(guide)
|
102
|
+
{
|
103
|
+
'tool' => guide[:tool].tool_name,
|
104
|
+
'label' => guide[:label],
|
105
|
+
'use_when' => guide[:use_when],
|
106
|
+
'avoid_when' => guide[:avoid_when],
|
107
|
+
'inputs' => guide[:inputs],
|
108
|
+
'example' => guide[:example]
|
109
|
+
}
|
110
|
+
end
|
111
|
+
|
112
|
+
def filter_entries(entries, query)
|
113
|
+
tokens = query.downcase.scan(/\w+/)
|
114
|
+
return entries if tokens.empty?
|
115
|
+
|
116
|
+
entries.select do |entry|
|
117
|
+
tokens.all? do |token|
|
118
|
+
entry.any? { |_, value| value_matches?(value, token) }
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def value_matches?(value, token)
|
124
|
+
case value
|
125
|
+
when String
|
126
|
+
value.downcase.include?(token)
|
127
|
+
when Array
|
128
|
+
value.any? { |element| element.downcase.include?(token) }
|
129
|
+
else
|
130
|
+
false
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
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 UncoveredLinesTool < BaseTool
|
9
|
+
description <<~DESC
|
10
|
+
Use this when the user wants to know which lines in a file still lack coverage.
|
11
|
+
Do not use this for overall percentages; coverage.summary is faster when counts are enough.
|
12
|
+
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
|
+
Example: "List uncovered lines for lib/simple_cov_mcp/tools/coverage_summary_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.uncovered_for(path)
|
22
|
+
::MCP::Tool::Response.new([{ type: 'text', text: JSON.pretty_generate(data) }])
|
23
|
+
rescue => e
|
24
|
+
handle_mcp_error(e, 'UncoveredLinesTool')
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../base_tool'
|
4
|
+
|
5
|
+
module SimpleCovMcp
|
6
|
+
module Tools
|
7
|
+
class VersionTool < BaseTool
|
8
|
+
description <<~DESC
|
9
|
+
Use this when the user or client needs to confirm which version of simplecov-mcp is running.
|
10
|
+
This tool takes no arguments and only returns the version string; avoid it for coverage data.
|
11
|
+
Output: plain text line "SimpleCovMcp version: x.y.z".
|
12
|
+
Example: "What version of simplecov-mcp is installed?".
|
13
|
+
DESC
|
14
|
+
input_schema(
|
15
|
+
type: 'object',
|
16
|
+
additionalProperties: false,
|
17
|
+
properties: {}
|
18
|
+
)
|
19
|
+
|
20
|
+
class << self
|
21
|
+
def call(server_context: nil, **_args)
|
22
|
+
::MCP::Tool::Response.new([
|
23
|
+
{ type: 'text', text: "SimpleCovMcp version: #{SimpleCovMcp::VERSION}" }
|
24
|
+
])
|
25
|
+
rescue => error
|
26
|
+
handle_mcp_error(error, 'version_tool')
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SimpleCovMcp
|
4
|
+
RESULTSET_CANDIDATES = [
|
5
|
+
'.resultset.json',
|
6
|
+
'coverage/.resultset.json',
|
7
|
+
'tmp/.resultset.json'
|
8
|
+
].freeze
|
9
|
+
|
10
|
+
module CovUtil
|
11
|
+
module_function
|
12
|
+
|
13
|
+
|
14
|
+
def log(msg)
|
15
|
+
path = File.expand_path('~/simplecov_mcp.log')
|
16
|
+
File.open(path, 'a') { |f| f.puts "[#{Time.now.iso8601}] #{msg}" }
|
17
|
+
rescue StandardError
|
18
|
+
# ignore logging failures
|
19
|
+
end
|
20
|
+
|
21
|
+
def find_resultset(root, resultset: nil)
|
22
|
+
if resultset && !resultset.to_s.empty?
|
23
|
+
path = File.absolute_path(resultset, root)
|
24
|
+
if (resolved = resolve_resultset_candidate(path, strict: true))
|
25
|
+
return resolved
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
if (env = ENV['SIMPLECOV_RESULTSET']) && !env.empty?
|
30
|
+
path = File.absolute_path(env, root)
|
31
|
+
if (resolved = resolve_resultset_candidate(path, strict: false))
|
32
|
+
return resolved
|
33
|
+
end
|
34
|
+
end
|
35
|
+
RESULTSET_CANDIDATES
|
36
|
+
.map { |p| File.absolute_path(p, root) }
|
37
|
+
.find { |p| File.file?(p) } or
|
38
|
+
raise "Could not find .resultset.json under #{root.inspect}; run tests or set SIMPLECOV_RESULTSET"
|
39
|
+
end
|
40
|
+
|
41
|
+
# returns { abs_path => {'lines' => [hits|nil,...]} }
|
42
|
+
def load_latest_coverage(root, resultset: nil)
|
43
|
+
rs = find_resultset(root, resultset: resultset)
|
44
|
+
raw = JSON.parse(File.read(rs))
|
45
|
+
_suite, data = raw.max_by { |_k, v| (v['timestamp'] || v['created_at'] || 0).to_i }
|
46
|
+
cov = data['coverage'] or raise "No 'coverage' key found in resultset file: #{rs}"
|
47
|
+
cov.transform_keys { |k| File.absolute_path(k, root) }
|
48
|
+
end
|
49
|
+
|
50
|
+
# Returns the timestamp (Integer seconds) for the latest coverage entry
|
51
|
+
# in the resultset. Used for staleness checks against source mtimes.
|
52
|
+
def latest_timestamp(root, resultset: nil)
|
53
|
+
rs = find_resultset(root, resultset: resultset)
|
54
|
+
raw = JSON.parse(File.read(rs))
|
55
|
+
_suite, data = raw.max_by { |_k, v| (v['timestamp'] || v['created_at'] || 0).to_i }
|
56
|
+
(data['timestamp'] || data['created_at'] || 0).to_i
|
57
|
+
end
|
58
|
+
|
59
|
+
def resolve_resultset_candidate(path, strict:)
|
60
|
+
return path if File.file?(path)
|
61
|
+
if File.directory?(path)
|
62
|
+
candidate = File.join(path, '.resultset.json')
|
63
|
+
return candidate if File.file?(candidate)
|
64
|
+
raise "No .resultset.json found in directory: #{path}" if strict
|
65
|
+
return nil
|
66
|
+
end
|
67
|
+
raise "Specified resultset not found: #{path}" if strict
|
68
|
+
nil
|
69
|
+
end
|
70
|
+
|
71
|
+
def lookup_lines(cov, file_abs)
|
72
|
+
if (h = cov[file_abs]) && h['lines'].is_a?(Array)
|
73
|
+
return h['lines']
|
74
|
+
end
|
75
|
+
|
76
|
+
# try without current working directory prefix
|
77
|
+
cwd = Dir.pwd
|
78
|
+
without = file_abs.sub(/\A#{Regexp.escape(cwd)}\//, '')
|
79
|
+
if (h = cov[without]) && h['lines'].is_a?(Array)
|
80
|
+
return h['lines']
|
81
|
+
end
|
82
|
+
|
83
|
+
# fallback: basename match
|
84
|
+
base = File.basename(file_abs)
|
85
|
+
kv = cov.find { |k, v| File.basename(k) == base && v['lines'].is_a?(Array) }
|
86
|
+
kv and return kv[1]['lines']
|
87
|
+
|
88
|
+
raise "No coverage entry found for #{file_abs}"
|
89
|
+
end
|
90
|
+
|
91
|
+
def summary(arr)
|
92
|
+
total = 0
|
93
|
+
covered = 0
|
94
|
+
arr.each do |hits|
|
95
|
+
next if hits.nil?
|
96
|
+
total += 1
|
97
|
+
covered += 1 if hits.to_i > 0
|
98
|
+
end
|
99
|
+
pct = total.zero? ? 100.0 : ((covered.to_f * 100.0 / total) * 100).round / 100.0
|
100
|
+
{ 'covered' => covered, 'total' => total, 'pct' => pct }
|
101
|
+
end
|
102
|
+
|
103
|
+
def uncovered(arr)
|
104
|
+
out = []
|
105
|
+
arr.each_with_index do |hits, i|
|
106
|
+
next if hits.nil?
|
107
|
+
out << (i + 1) if hits.to_i.zero?
|
108
|
+
end
|
109
|
+
out
|
110
|
+
end
|
111
|
+
|
112
|
+
def detailed(arr)
|
113
|
+
rows = []
|
114
|
+
arr.each_with_index do |hits, i|
|
115
|
+
next if hits.nil?
|
116
|
+
h = hits.to_i
|
117
|
+
rows << { 'line' => i + 1, 'hits' => h, 'covered' => h.positive? }
|
118
|
+
end
|
119
|
+
rows
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'time'
|
5
|
+
require 'pathname'
|
6
|
+
require 'mcp'
|
7
|
+
require 'mcp/server/transports/stdio_transport'
|
8
|
+
require 'awesome_print'
|
9
|
+
|
10
|
+
require_relative 'simple_cov_mcp/version'
|
11
|
+
require_relative 'simple_cov_mcp/util'
|
12
|
+
require_relative 'simple_cov_mcp/errors'
|
13
|
+
require_relative 'simple_cov_mcp/error_handler'
|
14
|
+
require_relative 'simple_cov_mcp/error_handler_factory'
|
15
|
+
require_relative 'simple_cov_mcp/model'
|
16
|
+
require_relative 'simple_cov_mcp/base_tool'
|
17
|
+
require_relative 'simple_cov_mcp/tools/coverage_raw_tool'
|
18
|
+
require_relative 'simple_cov_mcp/tools/coverage_summary_tool'
|
19
|
+
require_relative 'simple_cov_mcp/tools/uncovered_lines_tool'
|
20
|
+
require_relative 'simple_cov_mcp/tools/coverage_detailed_tool'
|
21
|
+
require_relative 'simple_cov_mcp/tools/all_files_coverage_tool'
|
22
|
+
require_relative 'simple_cov_mcp/tools/coverage_table_tool'
|
23
|
+
require_relative 'simple_cov_mcp/tools/version_tool'
|
24
|
+
require_relative 'simple_cov_mcp/tools/help_tool'
|
25
|
+
require_relative 'simple_cov_mcp/mcp_server'
|
26
|
+
require_relative 'simple_cov_mcp/cli'
|
27
|
+
|
28
|
+
module SimpleCovMcp
|
29
|
+
def self.run(argv)
|
30
|
+
# Determine whether to run CLI or MCP server based on arguments and environment
|
31
|
+
if should_run_cli?(argv)
|
32
|
+
CoverageCLI.new(error_handler: ErrorHandlerFactory.for_cli).run(argv)
|
33
|
+
else
|
34
|
+
MCPServer.new.run
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# For library usage, allow configuration of error handling.
|
39
|
+
# This method is intended for applications that want to embed simplecov-mcp
|
40
|
+
# functionality without the CLI behavior of showing friendly error messages
|
41
|
+
# and exiting. Instead, it raises custom exceptions that can be caught.
|
42
|
+
#
|
43
|
+
# Usage:
|
44
|
+
# # Basic usage - raises custom exceptions on errors
|
45
|
+
# SimpleCov::Mcp.run_as_library(['summary', 'lib/foo.rb'])
|
46
|
+
#
|
47
|
+
# # With custom error handler (e.g., disable logging)
|
48
|
+
# handler = SimpleCov::Mcp::ErrorHandler.new(log_errors: false)
|
49
|
+
# SimpleCov::Mcp.run_as_library(['summary', 'lib/foo.rb'], error_handler: handler)
|
50
|
+
#
|
51
|
+
# # Exception handling
|
52
|
+
# begin
|
53
|
+
# SimpleCov::Mcp.run_as_library(['summary', 'missing.rb'])
|
54
|
+
# rescue SimpleCov::Mcp::FileError => e
|
55
|
+
# puts "File not found: #{e.user_friendly_message}"
|
56
|
+
# rescue SimpleCov::Mcp::CoverageDataError => e
|
57
|
+
# puts "Coverage issue: #{e.user_friendly_message}"
|
58
|
+
# end
|
59
|
+
def self.run_as_library(argv, error_handler: nil)
|
60
|
+
# Set global error handler for library usage (affects shared components like MCP tools)
|
61
|
+
SimpleCovMcp.error_handler = error_handler || ErrorHandlerFactory.for_library
|
62
|
+
|
63
|
+
model = CoverageModel.new
|
64
|
+
execute_library_command(model, argv)
|
65
|
+
rescue SimpleCovMcp::Error => e
|
66
|
+
raise e # Re-raise custom errors for library users to catch
|
67
|
+
rescue => e
|
68
|
+
SimpleCovMcp.error_handler.handle_error(e, context: 'library execution')
|
69
|
+
raise e # Re-raise for library users to handle
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
def self.execute_library_command(model, argv)
|
75
|
+
return model.all_files if argv.empty?
|
76
|
+
|
77
|
+
unless argv.length >= 2
|
78
|
+
raise UsageError.new("Invalid arguments. Use: [] for all files, or [command, path] for specific file")
|
79
|
+
end
|
80
|
+
|
81
|
+
command, path = argv[0], argv[1]
|
82
|
+
case command
|
83
|
+
when 'summary' then model.summary_for(path)
|
84
|
+
when 'raw' then model.raw_for(path)
|
85
|
+
when 'uncovered' then model.uncovered_for(path)
|
86
|
+
when 'detailed' then model.detailed_for(path)
|
87
|
+
else
|
88
|
+
raise UsageError.new("Unknown command: #{command}. Use: summary, raw, uncovered, or detailed")
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def self.should_run_cli?(argv)
|
93
|
+
# Force CLI mode if environment variable is set
|
94
|
+
return true if ENV['SIMPLECOV_MCP_CLI'] == '1'
|
95
|
+
|
96
|
+
# If a subcommand is provided, run CLI
|
97
|
+
return true if CoverageCLI::SUBCOMMANDS.include?(argv[0])
|
98
|
+
|
99
|
+
# If interactive TTY, prefer CLI; else (e.g., pipes), run MCP server
|
100
|
+
STDIN.tty?
|
101
|
+
end
|
102
|
+
end
|
data/lib/simplecov_mcp.rb
CHANGED
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
require 'simple_cov_mcp/tools/all_files_coverage_tool'
|
5
|
+
|
6
|
+
RSpec.describe SimpleCovMcp::Tools::AllFilesCoverageTool do
|
7
|
+
let(:root) { (FIXTURES / 'project1').to_s }
|
8
|
+
let(:server_context) { instance_double('ServerContext').as_null_object }
|
9
|
+
|
10
|
+
before do
|
11
|
+
# Stub a response object that accepts a meta keyword, like the real one
|
12
|
+
stub_const('MCP::Tool::Response', Class.new do
|
13
|
+
attr_reader :payload, :meta
|
14
|
+
def initialize(payload, meta: nil)
|
15
|
+
@payload = payload
|
16
|
+
@meta = meta
|
17
|
+
end
|
18
|
+
end)
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'returns files with counts (total/ok/stale) as JSON' do
|
22
|
+
model = instance_double(SimpleCovMcp::CoverageModel)
|
23
|
+
allow(SimpleCovMcp::CoverageModel).to receive(:new).and_return(model)
|
24
|
+
allow(model).to receive(:all_files).and_return([
|
25
|
+
{ 'file' => "#{root}/lib/foo.rb", 'percentage' => 100.0, 'covered' => 10, 'total' => 10, 'stale' => false },
|
26
|
+
{ 'file' => "#{root}/lib/bar.rb", 'percentage' => 50.0, 'covered' => 5, 'total' => 10, 'stale' => true }
|
27
|
+
])
|
28
|
+
|
29
|
+
response = described_class.call(root: root, server_context: server_context)
|
30
|
+
entry = response.payload.first
|
31
|
+
json = JSON.parse(entry['text'])
|
32
|
+
|
33
|
+
expect(json).to have_key('files')
|
34
|
+
files = json['files']
|
35
|
+
counts = json['counts']
|
36
|
+
|
37
|
+
expect(files.length).to eq(2)
|
38
|
+
expect(counts).to include('total' => 2).or include(total: 2)
|
39
|
+
# ok + stale equals total
|
40
|
+
ok = counts[:ok] || counts['ok']
|
41
|
+
stale = counts[:stale] || counts['stale']
|
42
|
+
total = counts[:total] || counts['total']
|
43
|
+
expect(ok + stale).to eq(total)
|
44
|
+
expect(stale).to eq(1)
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe SimpleCovMcp::BaseTool do
|
6
|
+
let(:handler) { SimpleCovMcp::ErrorHandler.new(log_errors: true, show_stack_traces: false, logger: test_logger) }
|
7
|
+
let(:test_logger) do
|
8
|
+
Class.new do
|
9
|
+
attr_reader :messages
|
10
|
+
def initialize; @messages = []; end
|
11
|
+
def error(msg); @messages << msg; end
|
12
|
+
end.new
|
13
|
+
end
|
14
|
+
|
15
|
+
before do
|
16
|
+
@orig_handler = begin
|
17
|
+
SimpleCovMcp.error_handler
|
18
|
+
rescue StandardError
|
19
|
+
nil
|
20
|
+
end
|
21
|
+
SimpleCovMcp.error_handler = handler
|
22
|
+
# Stub MCP::Tool::Response once for all examples; capture the payload
|
23
|
+
fake_resp = Class.new do
|
24
|
+
attr_reader :payload
|
25
|
+
def initialize(payload) = @payload = payload
|
26
|
+
end
|
27
|
+
stub_const('MCP::Tool::Response', fake_resp)
|
28
|
+
end
|
29
|
+
|
30
|
+
after do
|
31
|
+
SimpleCovMcp.error_handler = @orig_handler if @orig_handler
|
32
|
+
end
|
33
|
+
|
34
|
+
shared_examples 'friendly response and logged' do
|
35
|
+
it 'returns friendly text and logs' do
|
36
|
+
resp = described_class.handle_mcp_error(error, tool)
|
37
|
+
expect(resp).to be_a(MCP::Tool::Response)
|
38
|
+
expect(resp.payload.first[:text]).to match(expected_pattern)
|
39
|
+
expect(test_logger.messages.join).to include(log_fragment)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
context 'with SimpleCovMcp::Error' do
|
44
|
+
let(:error) { SimpleCovMcp::UsageError.new('invalid args') }
|
45
|
+
let(:tool) { 'coverage_summary' }
|
46
|
+
let(:expected_pattern) { /Error: invalid args/ }
|
47
|
+
let(:log_fragment) { 'invalid args' }
|
48
|
+
include_examples 'friendly response and logged'
|
49
|
+
end
|
50
|
+
|
51
|
+
context 'with standard error' do
|
52
|
+
let(:error) { Errno::ENOENT.new('No such file or directory @ rb_sysopen - missing.rb') }
|
53
|
+
let(:tool) { 'coverage_raw' }
|
54
|
+
let(:expected_pattern) { /Error: .*File not found: missing.rb/ }
|
55
|
+
let(:log_fragment) { 'File not found' }
|
56
|
+
include_examples 'friendly response and logged'
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe SimpleCovMcp::CoverageCLI do
|
6
|
+
let(:root) { (FIXTURES / 'project1').to_s }
|
7
|
+
|
8
|
+
def run_cli_with_status(*argv)
|
9
|
+
cli = described_class.new
|
10
|
+
status = nil
|
11
|
+
out_str = err_str = nil
|
12
|
+
silence_output do |out, err|
|
13
|
+
begin
|
14
|
+
cli.run(argv.flatten)
|
15
|
+
status = 0
|
16
|
+
rescue SystemExit => e
|
17
|
+
status = e.status
|
18
|
+
end
|
19
|
+
out_str = out.string
|
20
|
+
err_str = err.string
|
21
|
+
end
|
22
|
+
[out_str, err_str, status]
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'shows help and exits 0' do
|
26
|
+
out, err, status = run_cli_with_status('--help')
|
27
|
+
expect(status).to eq(0)
|
28
|
+
expect(out).to include('Usage: simplecov-mcp')
|
29
|
+
expect(err).to eq("")
|
30
|
+
end
|
31
|
+
|
32
|
+
shared_examples 'maps error to exit 1 with message' do
|
33
|
+
before do
|
34
|
+
# Build a fake model that raises the specified error from the specified method
|
35
|
+
fake_model = Class.new do
|
36
|
+
def initialize(*) end
|
37
|
+
end
|
38
|
+
error_to_raise = raised_error
|
39
|
+
fake_model.define_method(model_method) { |*| raise error_to_raise }
|
40
|
+
stub_const('SimpleCovMcp::CoverageModel', fake_model)
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'exits with status 1 and friendly message' do
|
44
|
+
_out, err, status = run_cli_with_status(*invoke_args)
|
45
|
+
expect(status).to eq(1)
|
46
|
+
expect(err).to include(expected_message)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
context 'ENOENT mapping' do
|
51
|
+
let(:model_method) { :summary_for }
|
52
|
+
let(:raised_error) { Errno::ENOENT.new('No such file or directory @ rb_sysopen - missing.rb') }
|
53
|
+
let(:invoke_args) { ['summary', 'lib/missing.rb', '--root', root, '--resultset', 'coverage'] }
|
54
|
+
let(:expected_message) { 'File error: File not found: lib/missing.rb' }
|
55
|
+
include_examples 'maps error to exit 1 with message'
|
56
|
+
end
|
57
|
+
|
58
|
+
context 'EACCES mapping' do
|
59
|
+
let(:model_method) { :raw_for }
|
60
|
+
let(:raised_error) { Errno::EACCES.new('Permission denied @ rb_sysopen - secret.rb') }
|
61
|
+
let(:invoke_args) { ['raw', 'lib/secret.rb', '--root', root, '--resultset', 'coverage'] }
|
62
|
+
let(:expected_message) { 'Permission denied: lib/secret.rb' }
|
63
|
+
include_examples 'maps error to exit 1 with message'
|
64
|
+
end
|
65
|
+
|
66
|
+
it 'emits detailed stale coverage info and exits 1' do
|
67
|
+
begin
|
68
|
+
allow(SimpleCovMcp::CovUtil).to receive(:latest_timestamp).and_return(0)
|
69
|
+
_out, err, status = run_cli_with_status('summary', 'lib/foo.rb', '--root', root, '--resultset', 'coverage', '--stale', 'error')
|
70
|
+
expect(status).to eq(1)
|
71
|
+
expect(err).to include('Coverage data stale:')
|
72
|
+
expect(err).to match(/File\s+- time:/)
|
73
|
+
expect(err).to match('Coverage\s+- time:')
|
74
|
+
expect(err).to match(/Delta\s+- file is [+-]?\d+s newer than coverage/)
|
75
|
+
expect(err).to match('Resultset\s+-')
|
76
|
+
ensure
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
it 'honors --no-strict-staleness to disable checks' do
|
81
|
+
begin
|
82
|
+
allow(SimpleCovMcp::CovUtil).to receive(:latest_timestamp).and_return(0)
|
83
|
+
_out, err, status = run_cli_with_status('summary', 'lib/foo.rb', '--root', root, '--resultset', 'coverage', '--stale', 'off')
|
84
|
+
expect(status).to eq(0)
|
85
|
+
expect(err).to eq("")
|
86
|
+
ensure
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Note on text-mode source rendering tests:
|
91
|
+
# - "Text-mode source" refers to the plain-text source view (no ANSI colors)
|
92
|
+
# when passing --source or --source=uncovered (checkmarks/dots, line nums).
|
93
|
+
# - Direct tests are omitted here because behavior depends on how paths are
|
94
|
+
# resolved (relative vs absolute) in combination with --root/--resultset
|
95
|
+
# and whether the source file is readable. In uncovered mode, we observed
|
96
|
+
# a crash ("can't convert nil into Integer") when coverage arrays include
|
97
|
+
# nils or don’t line up with file lines. JSON paths avoid this formatting
|
98
|
+
# nuance and are already covered elsewhere.
|
99
|
+
# - Once the uncovered+source crash is guarded (treat out-of-range/nil hits
|
100
|
+
# defensively and only format integers where expected), we can add a
|
101
|
+
# regression: run `uncovered` with --source=uncovered against the fixtures
|
102
|
+
# and assert exit status 0 and rendered source.
|
103
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe SimpleCovMcp::CoverageCLI do
|
6
|
+
let(:root) { (FIXTURES / 'project1').to_s }
|
7
|
+
|
8
|
+
def run_cli_with_status(*argv)
|
9
|
+
cli = described_class.new
|
10
|
+
status = nil
|
11
|
+
out_str = err_str = nil
|
12
|
+
silence_output do |out, err|
|
13
|
+
begin
|
14
|
+
cli.run(argv.flatten)
|
15
|
+
status = 0
|
16
|
+
rescue SystemExit => e
|
17
|
+
status = e.status
|
18
|
+
end
|
19
|
+
out_str = out.string
|
20
|
+
err_str = err.string
|
21
|
+
end
|
22
|
+
[out_str, err_str, status]
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'renders uncovered source without error for fixture file' do
|
26
|
+
out, err, status = run_cli_with_status(
|
27
|
+
'uncovered', 'lib/foo.rb', '--root', root, '--resultset', 'coverage',
|
28
|
+
'--source=uncovered', '--source-context', '1', '--no-color'
|
29
|
+
)
|
30
|
+
expect(status).to eq(0)
|
31
|
+
expect(err).to eq("")
|
32
|
+
expect(out).to include('File: lib/foo.rb')
|
33
|
+
expect(out).to include('Uncovered lines: 2')
|
34
|
+
# Accept either rendered source table or fallback message
|
35
|
+
expect(out).to satisfy { |s| s.include?('Line') || s.include?('[source not available]') }
|
36
|
+
end
|
37
|
+
end
|