coverband 6.1.6 → 6.1.7
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/.github/workflows/main.yml +4 -4
- data/README.md +123 -0
- data/agents.md +217 -0
- data/bin/coverband-mcp +42 -0
- data/changes.md +18 -0
- data/coverband/log.272267 +1 -0
- data/coverband.gemspec +3 -1
- data/lib/coverband/collectors/route_tracker.rb +1 -1
- data/lib/coverband/collectors/view_tracker.rb +21 -13
- data/lib/coverband/configuration.rb +57 -18
- data/lib/coverband/integrations/resque.rb +2 -2
- data/lib/coverband/mcp/http_handler.rb +118 -0
- data/lib/coverband/mcp/server.rb +116 -0
- data/lib/coverband/mcp/tools/get_coverage_summary.rb +41 -0
- data/lib/coverband/mcp/tools/get_dead_methods.rb +69 -0
- data/lib/coverband/mcp/tools/get_file_coverage.rb +72 -0
- data/lib/coverband/mcp/tools/get_route_tracker_data.rb +60 -0
- data/lib/coverband/mcp/tools/get_translation_tracker_data.rb +60 -0
- data/lib/coverband/mcp/tools/get_uncovered_files.rb +73 -0
- data/lib/coverband/mcp/tools/get_view_tracker_data.rb +60 -0
- data/lib/coverband/mcp.rb +27 -0
- data/lib/coverband/reporters/base.rb +2 -4
- data/lib/coverband/reporters/web.rb +17 -14
- data/lib/coverband/utils/lines_classifier.rb +1 -1
- data/lib/coverband/utils/result.rb +2 -1
- data/lib/coverband/utils/source_file.rb +5 -5
- data/lib/coverband/utils/tasks.rb +31 -0
- data/lib/coverband/version.rb +1 -1
- data/lib/coverband.rb +2 -2
- data/test/coverband/file_store_integration_test.rb +72 -0
- data/test/coverband/file_store_redis_error_test.rb +56 -0
- data/test/coverband/github_issue_586_test.rb +46 -0
- data/test/coverband/initialization_timing_test.rb +71 -0
- data/test/coverband/mcp/http_handler_test.rb +159 -0
- data/test/coverband/mcp/security_test.rb +145 -0
- data/test/coverband/mcp/server_test.rb +125 -0
- data/test/coverband/mcp/tools/get_coverage_summary_test.rb +75 -0
- data/test/coverband/mcp/tools/get_dead_methods_test.rb +162 -0
- data/test/coverband/mcp/tools/get_file_coverage_test.rb +159 -0
- data/test/coverband/mcp/tools/get_route_tracker_data_test.rb +122 -0
- data/test/coverband/mcp/tools/get_translation_tracker_data_test.rb +122 -0
- data/test/coverband/mcp/tools/get_uncovered_files_test.rb +177 -0
- data/test/coverband/mcp/tools/get_view_tracker_data_test.rb +122 -0
- data/test/coverband/reporters/web_test.rb +5 -0
- data/test/coverband/tracker_initialization_test.rb +75 -0
- data/test/coverband/user_environment_simulation_test.rb +75 -0
- data/test/coverband/utils/lines_classifier_test.rb +1 -1
- data/test/integration/mcp_integration_test.rb +175 -0
- data/test/test_helper.rb +4 -5
- metadata +65 -6
|
@@ -29,8 +29,8 @@ if defined?(Coverband::COVERBAND_ALTERNATE_PATCH)
|
|
|
29
29
|
ensure
|
|
30
30
|
Coverband.report_coverage
|
|
31
31
|
end
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
alias_method :perform_without_coverband, :perform
|
|
33
|
+
alias_method :perform, :perform_with_coverband
|
|
34
34
|
end
|
|
35
35
|
else
|
|
36
36
|
Resque::Job.prepend(Coverband::ResqueWorker)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coverband
|
|
4
|
+
module MCP
|
|
5
|
+
# Rack middleware that adds MCP HTTP endpoint support.
|
|
6
|
+
# Can be used to wrap the existing Coverband::Reporters::Web app
|
|
7
|
+
# or mounted standalone.
|
|
8
|
+
#
|
|
9
|
+
# Usage with existing web UI:
|
|
10
|
+
# map "/coverage" do
|
|
11
|
+
# run Coverband::MCP::HttpHandler.new(Coverband::Reporters::Web.new)
|
|
12
|
+
# end
|
|
13
|
+
# # MCP endpoint available at POST /coverage/mcp
|
|
14
|
+
#
|
|
15
|
+
# Usage standalone:
|
|
16
|
+
# map "/mcp" do
|
|
17
|
+
# run Coverband::MCP::HttpHandler.new
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
class HttpHandler
|
|
21
|
+
MCP_PATH = "/mcp"
|
|
22
|
+
|
|
23
|
+
def initialize(app = nil)
|
|
24
|
+
@app = app
|
|
25
|
+
@server = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(env)
|
|
29
|
+
request = Rack::Request.new(env)
|
|
30
|
+
|
|
31
|
+
if mcp_request?(request)
|
|
32
|
+
handle_mcp_request(request)
|
|
33
|
+
elsif @app
|
|
34
|
+
@app.call(env)
|
|
35
|
+
else
|
|
36
|
+
not_found_response
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def mcp_request?(request)
|
|
43
|
+
request.post? && request.path_info.end_with?(MCP_PATH)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def handle_mcp_request(request)
|
|
47
|
+
# Check authentication if MCP password is configured
|
|
48
|
+
unless authenticate_mcp_request(request)
|
|
49
|
+
return [401, {
|
|
50
|
+
"Content-Type" => "application/json",
|
|
51
|
+
"Access-Control-Allow-Origin" => "*",
|
|
52
|
+
"Access-Control-Allow-Methods" => "POST, OPTIONS",
|
|
53
|
+
"Access-Control-Allow-Headers" => "Content-Type, Authorization",
|
|
54
|
+
"WWW-Authenticate" => 'Bearer realm="Coverband MCP"'
|
|
55
|
+
}, [JSON.generate({
|
|
56
|
+
"error" => "Authentication required",
|
|
57
|
+
"message" => "MCP access requires authentication. Provide Bearer token via Authorization header."
|
|
58
|
+
})]]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
body = request.body.read
|
|
62
|
+
json_request = JSON.parse(body)
|
|
63
|
+
response = mcp_server.handle_json(json_request)
|
|
64
|
+
|
|
65
|
+
# response might already be a JSON string, so check before converting
|
|
66
|
+
response_body = response.is_a?(String) ? response : response.to_json
|
|
67
|
+
|
|
68
|
+
[
|
|
69
|
+
200,
|
|
70
|
+
{
|
|
71
|
+
"content-type" => "application/json",
|
|
72
|
+
"access-control-allow-origin" => "*",
|
|
73
|
+
"access-control-allow-methods" => "POST, OPTIONS",
|
|
74
|
+
"access-control-allow-headers" => "Content-Type"
|
|
75
|
+
},
|
|
76
|
+
[response_body]
|
|
77
|
+
]
|
|
78
|
+
rescue JSON::ParserError => e
|
|
79
|
+
error_response(400, "Invalid JSON: #{e.message}")
|
|
80
|
+
rescue => e
|
|
81
|
+
error_response(500, "Server error: #{e.message}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def authenticate_mcp_request(request)
|
|
85
|
+
# If no MCP password is configured, allow access
|
|
86
|
+
mcp_password = Coverband.configuration.mcp_password
|
|
87
|
+
return true unless mcp_password
|
|
88
|
+
|
|
89
|
+
# Extract Bearer token from Authorization header
|
|
90
|
+
auth_header = request.get_header("HTTP_AUTHORIZATION")
|
|
91
|
+
return false unless auth_header
|
|
92
|
+
|
|
93
|
+
# Parse Bearer token
|
|
94
|
+
token = auth_header[/Bearer (.+)/, 1]
|
|
95
|
+
return false unless token
|
|
96
|
+
|
|
97
|
+
# Compare with configured MCP password
|
|
98
|
+
token == mcp_password
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def mcp_server
|
|
102
|
+
@server ||= Server.new
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def error_response(status, message)
|
|
106
|
+
[
|
|
107
|
+
status,
|
|
108
|
+
{"content-type" => "application/json"},
|
|
109
|
+
[{error: message}.to_json]
|
|
110
|
+
]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def not_found_response
|
|
114
|
+
[404, {"content-type" => "text/plain"}, ["Not Found"]]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tools/get_coverage_summary"
|
|
4
|
+
require_relative "tools/get_file_coverage"
|
|
5
|
+
require_relative "tools/get_uncovered_files"
|
|
6
|
+
require_relative "tools/get_dead_methods"
|
|
7
|
+
require_relative "tools/get_view_tracker_data"
|
|
8
|
+
require_relative "tools/get_route_tracker_data"
|
|
9
|
+
require_relative "tools/get_translation_tracker_data"
|
|
10
|
+
|
|
11
|
+
module Coverband
|
|
12
|
+
module MCP
|
|
13
|
+
class Server
|
|
14
|
+
attr_reader :mcp_server
|
|
15
|
+
|
|
16
|
+
DEFAULT_HTTP_PORT = 9023
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
# Ensure Coverband is configured
|
|
20
|
+
Coverband.configure unless Coverband.configured?
|
|
21
|
+
|
|
22
|
+
# Security check: Ensure MCP is enabled and environment is allowed
|
|
23
|
+
unless Coverband.configuration.mcp_enabled?
|
|
24
|
+
raise SecurityError, "MCP is not enabled. Set config.mcp_enabled = true and ensure the current environment is in mcp_allowed_environments."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@mcp_server = ::MCP::Server.new(
|
|
28
|
+
name: "coverband",
|
|
29
|
+
version: Coverband::VERSION,
|
|
30
|
+
instructions: "Coverband production code coverage MCP server. " \
|
|
31
|
+
"Query coverage data, find dead code, and analyze view/route/translation usage.",
|
|
32
|
+
tools: tools
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run_stdio
|
|
37
|
+
transport = ::MCP::Server::Transports::StdioTransport.new(@mcp_server)
|
|
38
|
+
transport.open
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run_http(port: DEFAULT_HTTP_PORT, host: "localhost")
|
|
42
|
+
require "rack"
|
|
43
|
+
require "rackup"
|
|
44
|
+
|
|
45
|
+
transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(@mcp_server)
|
|
46
|
+
@mcp_server.transport = transport
|
|
47
|
+
|
|
48
|
+
app = create_rack_app(transport)
|
|
49
|
+
|
|
50
|
+
puts <<~MESSAGE
|
|
51
|
+
=== Coverband MCP Server (HTTP) ===
|
|
52
|
+
|
|
53
|
+
🔒 SECURITY NOTICE:
|
|
54
|
+
This server exposes production coverage data.
|
|
55
|
+
Ensure proper network security (firewall, VPN, etc.)
|
|
56
|
+
Environment: #{(defined?(Rails) && Rails.respond_to?(:env) && Rails.env) || ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"}
|
|
57
|
+
Authentication: #{Coverband.configuration.mcp_password ? "✓ Enabled" : "⚠️ DISABLED"}
|
|
58
|
+
|
|
59
|
+
Server running at http://#{host}:#{port}
|
|
60
|
+
|
|
61
|
+
Available tools:
|
|
62
|
+
- get_coverage_summary
|
|
63
|
+
- get_file_coverage
|
|
64
|
+
- get_uncovered_files
|
|
65
|
+
- get_dead_methods
|
|
66
|
+
- get_view_tracker_data
|
|
67
|
+
- get_route_tracker_data
|
|
68
|
+
- get_translation_tracker_data
|
|
69
|
+
|
|
70
|
+
For Claude Desktop, configure with:
|
|
71
|
+
{
|
|
72
|
+
"mcpServers": {
|
|
73
|
+
"coverband": {
|
|
74
|
+
"command": "npx",
|
|
75
|
+
"args": ["mcp-remote", "http://#{host}:#{port}"]
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
Press Ctrl+C to stop the server
|
|
81
|
+
MESSAGE
|
|
82
|
+
|
|
83
|
+
Rackup::Handler.get("puma").run(app, Port: port, Host: host, Silent: true)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def handle_json(json_request)
|
|
87
|
+
@mcp_server.handle_json(json_request)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def create_rack_app(transport)
|
|
93
|
+
Rack::Builder.new do
|
|
94
|
+
use Rack::CommonLogger
|
|
95
|
+
|
|
96
|
+
run lambda { |env|
|
|
97
|
+
request = Rack::Request.new(env)
|
|
98
|
+
transport.handle_request(request)
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def tools
|
|
104
|
+
[
|
|
105
|
+
Tools::GetCoverageSummary,
|
|
106
|
+
Tools::GetFileCoverage,
|
|
107
|
+
Tools::GetUncoveredFiles,
|
|
108
|
+
Tools::GetDeadMethods,
|
|
109
|
+
Tools::GetViewTrackerData,
|
|
110
|
+
Tools::GetRouteTrackerData,
|
|
111
|
+
Tools::GetTranslationTrackerData
|
|
112
|
+
]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coverband
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class GetCoverageSummary < ::MCP::Tool
|
|
7
|
+
description "Get overall production code coverage statistics including total files, " \
|
|
8
|
+
"lines of code, lines covered, and coverage percentage."
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {}
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
def self.call(server_context:, **)
|
|
15
|
+
store = Coverband.configuration.store
|
|
16
|
+
report = Coverband::Reporters::JSONReport.new(store)
|
|
17
|
+
data = JSON.parse(report.report)
|
|
18
|
+
|
|
19
|
+
summary = {
|
|
20
|
+
total_files: data["total_files"],
|
|
21
|
+
lines_of_code: data["lines_of_code"],
|
|
22
|
+
lines_covered: data["lines_covered"],
|
|
23
|
+
lines_missed: data["lines_missed"],
|
|
24
|
+
covered_percent: data["covered_percent"],
|
|
25
|
+
covered_strength: data["covered_strength"]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
::MCP::Tool::Response.new([{
|
|
29
|
+
type: "text",
|
|
30
|
+
text: JSON.pretty_generate(summary)
|
|
31
|
+
}])
|
|
32
|
+
rescue => e
|
|
33
|
+
::MCP::Tool::Response.new([{
|
|
34
|
+
type: "text",
|
|
35
|
+
text: "Error getting coverage summary: #{e.message}"
|
|
36
|
+
}])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coverband
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class GetDeadMethods < ::MCP::Tool
|
|
7
|
+
description "Analyze code coverage to find methods that have never been executed in production. " \
|
|
8
|
+
"Requires Ruby 2.6+ with RubyVM::AbstractSyntaxTree support."
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
file_pattern: {
|
|
13
|
+
type: "string",
|
|
14
|
+
description: "Optional glob pattern to filter files (e.g., 'app/models/**/*.rb')"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def self.call(server_context:, file_pattern: nil, **)
|
|
20
|
+
unless defined?(RubyVM::AbstractSyntaxTree)
|
|
21
|
+
return ::MCP::Tool::Response.new([{
|
|
22
|
+
type: "text",
|
|
23
|
+
text: "Dead method detection requires Ruby 2.6+ with RubyVM::AbstractSyntaxTree support."
|
|
24
|
+
}])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
dead_methods = Coverband::Utils::DeadMethods.scan_all
|
|
28
|
+
|
|
29
|
+
if file_pattern
|
|
30
|
+
dead_methods = dead_methods.select do |method|
|
|
31
|
+
File.fnmatch(file_pattern, method[:file_path], File::FNM_PATHNAME)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Group by file for easier reading
|
|
36
|
+
grouped = dead_methods.group_by { |m| m[:file_path] }
|
|
37
|
+
|
|
38
|
+
result = grouped.map do |file_path, methods|
|
|
39
|
+
{
|
|
40
|
+
file: file_path,
|
|
41
|
+
dead_methods: methods.map do |m|
|
|
42
|
+
{
|
|
43
|
+
class_name: m[:class_name],
|
|
44
|
+
method_name: m[:method_name],
|
|
45
|
+
line_number: m[:line_number]
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
::MCP::Tool::Response.new([{
|
|
52
|
+
type: "text",
|
|
53
|
+
text: JSON.pretty_generate({
|
|
54
|
+
total_dead_methods: dead_methods.length,
|
|
55
|
+
files_with_dead_methods: grouped.keys.length,
|
|
56
|
+
file_pattern: file_pattern,
|
|
57
|
+
results: result
|
|
58
|
+
})
|
|
59
|
+
}])
|
|
60
|
+
rescue => e
|
|
61
|
+
::MCP::Tool::Response.new([{
|
|
62
|
+
type: "text",
|
|
63
|
+
text: "Error analyzing dead methods: #{e.message}"
|
|
64
|
+
}])
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coverband
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class GetFileCoverage < ::MCP::Tool
|
|
7
|
+
description "Get detailed line-by-line coverage data for a specific file. " \
|
|
8
|
+
"Returns coverage percentage, lines covered/missed, and per-line hit counts."
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
filename: {
|
|
13
|
+
type: "string",
|
|
14
|
+
description: "Full or partial path to the file (e.g., 'app/models/user.rb')"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
required: ["filename"]
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def self.call(filename:, server_context:, **)
|
|
21
|
+
store = Coverband.configuration.store
|
|
22
|
+
report = Coverband::Reporters::JSONReport.new(store, {
|
|
23
|
+
filename: filename,
|
|
24
|
+
line_coverage: true
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
data = JSON.parse(report.report)
|
|
28
|
+
|
|
29
|
+
if data["files"].nil? || data["files"].empty?
|
|
30
|
+
return ::MCP::Tool::Response.new([{
|
|
31
|
+
type: "text",
|
|
32
|
+
text: "No coverage data found for file: #{filename}"
|
|
33
|
+
}])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Find matching file(s)
|
|
37
|
+
matching_files = data["files"].select { |path, _| path.include?(filename) }
|
|
38
|
+
|
|
39
|
+
if matching_files.empty?
|
|
40
|
+
return ::MCP::Tool::Response.new([{
|
|
41
|
+
type: "text",
|
|
42
|
+
text: "No coverage data found for file matching: #{filename}"
|
|
43
|
+
}])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
result = matching_files.transform_values do |file_data|
|
|
47
|
+
{
|
|
48
|
+
filename: file_data["filename"],
|
|
49
|
+
covered_percent: file_data["covered_percent"],
|
|
50
|
+
lines_of_code: file_data["lines_of_code"],
|
|
51
|
+
lines_covered: file_data["lines_covered"],
|
|
52
|
+
lines_missed: file_data["lines_missed"],
|
|
53
|
+
runtime_percentage: file_data["runtime_percentage"],
|
|
54
|
+
never_loaded: file_data["never_loaded"],
|
|
55
|
+
coverage: file_data["coverage"]
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
::MCP::Tool::Response.new([{
|
|
60
|
+
type: "text",
|
|
61
|
+
text: JSON.pretty_generate(result)
|
|
62
|
+
}])
|
|
63
|
+
rescue => e
|
|
64
|
+
::MCP::Tool::Response.new([{
|
|
65
|
+
type: "text",
|
|
66
|
+
text: "Error getting file coverage: #{e.message}"
|
|
67
|
+
}])
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coverband
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class GetRouteTrackerData < ::MCP::Tool
|
|
7
|
+
description "Get Rails route usage tracking data. Shows which routes have been hit " \
|
|
8
|
+
"in production and which have never been accessed."
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
show_unused_only: {
|
|
13
|
+
type: "boolean",
|
|
14
|
+
description: "Only return unused routes (default: false)"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def self.call(server_context:, show_unused_only: false, **)
|
|
20
|
+
tracker = Coverband.configuration.route_tracker
|
|
21
|
+
|
|
22
|
+
unless tracker
|
|
23
|
+
return ::MCP::Tool::Response.new([{
|
|
24
|
+
type: "text",
|
|
25
|
+
text: "Route tracking is not enabled. Enable it with `config.track_routes = true` in your coverband configuration."
|
|
26
|
+
}])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
data = JSON.parse(tracker.as_json)
|
|
30
|
+
|
|
31
|
+
result = if show_unused_only
|
|
32
|
+
{
|
|
33
|
+
tracking_since: tracker.tracking_since,
|
|
34
|
+
unused_routes: data["unused_keys"] || [],
|
|
35
|
+
total_unused: data["unused_keys"]&.length || 0
|
|
36
|
+
}
|
|
37
|
+
else
|
|
38
|
+
{
|
|
39
|
+
tracking_since: tracker.tracking_since,
|
|
40
|
+
used_routes: data["used_keys"] || [],
|
|
41
|
+
unused_routes: data["unused_keys"] || [],
|
|
42
|
+
total_used: data["used_keys"]&.length || 0,
|
|
43
|
+
total_unused: data["unused_keys"]&.length || 0
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
::MCP::Tool::Response.new([{
|
|
48
|
+
type: "text",
|
|
49
|
+
text: JSON.pretty_generate(result)
|
|
50
|
+
}])
|
|
51
|
+
rescue => e
|
|
52
|
+
::MCP::Tool::Response.new([{
|
|
53
|
+
type: "text",
|
|
54
|
+
text: "Error getting route tracker data: #{e.message}"
|
|
55
|
+
}])
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coverband
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class GetTranslationTrackerData < ::MCP::Tool
|
|
7
|
+
description "Get I18n translation key usage tracking data. Shows which translation " \
|
|
8
|
+
"keys have been used in production and which have never been accessed."
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
show_unused_only: {
|
|
13
|
+
type: "boolean",
|
|
14
|
+
description: "Only return unused translation keys (default: false)"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def self.call(server_context:, show_unused_only: false, **)
|
|
20
|
+
tracker = Coverband.configuration.translations_tracker
|
|
21
|
+
|
|
22
|
+
unless tracker
|
|
23
|
+
return ::MCP::Tool::Response.new([{
|
|
24
|
+
type: "text",
|
|
25
|
+
text: "Translation tracking is not enabled. Enable it with `config.track_translations = true` in your coverband configuration."
|
|
26
|
+
}])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
data = JSON.parse(tracker.as_json)
|
|
30
|
+
|
|
31
|
+
result = if show_unused_only
|
|
32
|
+
{
|
|
33
|
+
tracking_since: tracker.tracking_since,
|
|
34
|
+
unused_translations: data["unused_keys"] || [],
|
|
35
|
+
total_unused: data["unused_keys"]&.length || 0
|
|
36
|
+
}
|
|
37
|
+
else
|
|
38
|
+
{
|
|
39
|
+
tracking_since: tracker.tracking_since,
|
|
40
|
+
used_translations: data["used_keys"] || [],
|
|
41
|
+
unused_translations: data["unused_keys"] || [],
|
|
42
|
+
total_used: data["used_keys"]&.length || 0,
|
|
43
|
+
total_unused: data["unused_keys"]&.length || 0
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
::MCP::Tool::Response.new([{
|
|
48
|
+
type: "text",
|
|
49
|
+
text: JSON.pretty_generate(result)
|
|
50
|
+
}])
|
|
51
|
+
rescue => e
|
|
52
|
+
::MCP::Tool::Response.new([{
|
|
53
|
+
type: "text",
|
|
54
|
+
text: "Error getting translation tracker data: #{e.message}"
|
|
55
|
+
}])
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coverband
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class GetUncoveredFiles < ::MCP::Tool
|
|
7
|
+
description "Get files with coverage below a specified threshold. " \
|
|
8
|
+
"Useful for finding code that may need more production testing or could be dead code."
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
threshold: {
|
|
13
|
+
type: "number",
|
|
14
|
+
description: "Coverage percentage threshold (default: 50). Files below this are returned."
|
|
15
|
+
},
|
|
16
|
+
include_never_loaded: {
|
|
17
|
+
type: "boolean",
|
|
18
|
+
description: "Include files that were never loaded in production (default: true)"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def self.call(server_context:, threshold: 50, include_never_loaded: true, **)
|
|
24
|
+
store = Coverband.configuration.store
|
|
25
|
+
report = Coverband::Reporters::JSONReport.new(store, line_coverage: false)
|
|
26
|
+
data = JSON.parse(report.report)
|
|
27
|
+
|
|
28
|
+
files = data["files"] || {}
|
|
29
|
+
|
|
30
|
+
uncovered = files.select do |_path, file_data|
|
|
31
|
+
percent = file_data["covered_percent"] || 0
|
|
32
|
+
never_loaded = file_data["never_loaded"]
|
|
33
|
+
|
|
34
|
+
if include_never_loaded
|
|
35
|
+
percent < threshold || never_loaded
|
|
36
|
+
else
|
|
37
|
+
percent < threshold && !never_loaded
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Sort by coverage percentage ascending (least covered first)
|
|
42
|
+
sorted = uncovered.sort_by { |_path, data| data["covered_percent"] || 0 }
|
|
43
|
+
|
|
44
|
+
result = sorted.map do |path, file_data|
|
|
45
|
+
{
|
|
46
|
+
file: path,
|
|
47
|
+
covered_percent: file_data["covered_percent"],
|
|
48
|
+
lines_of_code: file_data["lines_of_code"],
|
|
49
|
+
lines_covered: file_data["lines_covered"],
|
|
50
|
+
lines_missed: file_data["lines_missed"],
|
|
51
|
+
never_loaded: file_data["never_loaded"]
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
::MCP::Tool::Response.new([{
|
|
56
|
+
type: "text",
|
|
57
|
+
text: JSON.pretty_generate({
|
|
58
|
+
threshold: threshold,
|
|
59
|
+
include_never_loaded: include_never_loaded,
|
|
60
|
+
total_uncovered_files: result.length,
|
|
61
|
+
files: result
|
|
62
|
+
})
|
|
63
|
+
}])
|
|
64
|
+
rescue => e
|
|
65
|
+
::MCP::Tool::Response.new([{
|
|
66
|
+
type: "text",
|
|
67
|
+
text: "Error getting uncovered files: #{e.message}"
|
|
68
|
+
}])
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coverband
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class GetViewTrackerData < ::MCP::Tool
|
|
7
|
+
description "Get Rails view template usage tracking data. Shows which view templates " \
|
|
8
|
+
"have been rendered in production and which have never been accessed."
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
show_unused_only: {
|
|
13
|
+
type: "boolean",
|
|
14
|
+
description: "Only return unused views (default: false)"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def self.call(server_context:, show_unused_only: false, **)
|
|
20
|
+
tracker = Coverband.configuration.view_tracker
|
|
21
|
+
|
|
22
|
+
unless tracker
|
|
23
|
+
return ::MCP::Tool::Response.new([{
|
|
24
|
+
type: "text",
|
|
25
|
+
text: "View tracking is not enabled. Enable it with `config.track_views = true` in your coverband configuration."
|
|
26
|
+
}])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
data = JSON.parse(tracker.as_json)
|
|
30
|
+
|
|
31
|
+
result = if show_unused_only
|
|
32
|
+
{
|
|
33
|
+
tracking_since: tracker.tracking_since,
|
|
34
|
+
unused_views: data["unused_keys"] || [],
|
|
35
|
+
total_unused: data["unused_keys"]&.length || 0
|
|
36
|
+
}
|
|
37
|
+
else
|
|
38
|
+
{
|
|
39
|
+
tracking_since: tracker.tracking_since,
|
|
40
|
+
used_views: data["used_keys"] || [],
|
|
41
|
+
unused_views: data["unused_keys"] || [],
|
|
42
|
+
total_used: data["used_keys"]&.length || 0,
|
|
43
|
+
total_unused: data["unused_keys"]&.length || 0
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
::MCP::Tool::Response.new([{
|
|
48
|
+
type: "text",
|
|
49
|
+
text: JSON.pretty_generate(result)
|
|
50
|
+
}])
|
|
51
|
+
rescue => e
|
|
52
|
+
::MCP::Tool::Response.new([{
|
|
53
|
+
type: "text",
|
|
54
|
+
text: "Error getting view tracker data: #{e.message}"
|
|
55
|
+
}])
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|