simplecov-mcp 1.0.1 → 2.0.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 +98 -50
- data/docs/{ARCHITECTURE.md → dev/ARCHITECTURE.md} +11 -10
- data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
- data/docs/{DEVELOPMENT.md → dev/DEVELOPMENT.md} +2 -1
- data/docs/dev/README.md +10 -0
- data/docs/dev/RELEASING.md +146 -0
- data/docs/{arch-decisions → dev/arch-decisions}/001-x-arch-decision.md +3 -1
- data/docs/{arch-decisions → dev/arch-decisions}/002-x-arch-decision.md +7 -5
- data/docs/{arch-decisions → dev/arch-decisions}/003-x-arch-decision.md +2 -0
- data/docs/{arch-decisions → dev/arch-decisions}/004-x-arch-decision.md +6 -2
- data/docs/{arch-decisions → dev/arch-decisions}/005-x-arch-decision.md +4 -2
- data/docs/{arch-decisions → dev/arch-decisions}/README.md +3 -3
- data/docs/{presentations → dev/presentations}/simplecov-mcp-presentation.md +28 -22
- data/docs/fixtures/demo_project/README.md +9 -0
- data/docs/{ADVANCED_USAGE.md → user/ADVANCED_USAGE.md} +129 -319
- data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
- data/docs/user/CLI_USAGE.md +750 -0
- data/docs/{ERROR_HANDLING.md → user/ERROR_HANDLING.md} +12 -12
- data/docs/user/EXAMPLES.md +588 -0
- data/docs/user/INSTALLATION.md +130 -0
- data/docs/{LIBRARY_API.md → user/LIBRARY_API.md} +90 -32
- data/docs/{MCP_INTEGRATION.md → user/MCP_INTEGRATION.md} +36 -34
- data/docs/user/README.md +14 -0
- data/docs/{TROUBLESHOOTING.md → user/TROUBLESHOOTING.md} +21 -100
- data/docs/user/V2-BREAKING-CHANGES.md +472 -0
- data/exe/simplecov-mcp +1 -1
- data/lib/simplecov_mcp/{cli_config.rb → app_config.rb} +12 -12
- data/lib/simplecov_mcp/app_context.rb +1 -1
- data/lib/simplecov_mcp/base_tool.rb +66 -38
- data/lib/simplecov_mcp/cli.rb +67 -123
- data/lib/simplecov_mcp/commands/base_command.rb +16 -27
- data/lib/simplecov_mcp/commands/command_factory.rb +8 -2
- data/lib/simplecov_mcp/commands/detailed_command.rb +16 -2
- data/lib/simplecov_mcp/commands/list_command.rb +1 -1
- data/lib/simplecov_mcp/commands/raw_command.rb +18 -2
- data/lib/simplecov_mcp/commands/summary_command.rb +20 -3
- data/lib/simplecov_mcp/commands/totals_command.rb +53 -0
- data/lib/simplecov_mcp/commands/uncovered_command.rb +24 -5
- data/lib/simplecov_mcp/commands/validate_command.rb +60 -0
- data/lib/simplecov_mcp/commands/version_command.rb +19 -4
- data/lib/simplecov_mcp/config_parser.rb +32 -0
- data/lib/simplecov_mcp/constants.rb +3 -3
- data/lib/simplecov_mcp/coverage_reporter.rb +31 -0
- data/lib/simplecov_mcp/error_handler.rb +81 -40
- data/lib/simplecov_mcp/error_handler_factory.rb +2 -2
- data/lib/simplecov_mcp/errors.rb +12 -19
- data/lib/simplecov_mcp/formatters/source_formatter.rb +23 -19
- data/lib/simplecov_mcp/formatters.rb +51 -0
- data/lib/simplecov_mcp/mcp_server.rb +9 -7
- data/lib/simplecov_mcp/mode_detector.rb +6 -5
- data/lib/simplecov_mcp/model.rb +122 -88
- data/lib/simplecov_mcp/option_normalizers.rb +39 -18
- data/lib/simplecov_mcp/option_parser_builder.rb +85 -72
- data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +3 -5
- data/lib/simplecov_mcp/option_parsers/error_helper.rb +18 -17
- data/lib/simplecov_mcp/path_relativizer.rb +17 -14
- data/lib/simplecov_mcp/predicate_evaluator.rb +72 -0
- data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/project_totals_presenter.rb +27 -0
- data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +14 -18
- data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +7 -9
- data/lib/simplecov_mcp/resultset_loader.rb +20 -25
- data/lib/simplecov_mcp/staleness_checker.rb +50 -46
- data/lib/simplecov_mcp/table_formatter.rb +64 -0
- data/lib/simplecov_mcp/tools/all_files_coverage_tool.rb +20 -50
- data/lib/simplecov_mcp/tools/coverage_detailed_tool.rb +13 -7
- data/lib/simplecov_mcp/tools/coverage_raw_tool.rb +12 -7
- data/lib/simplecov_mcp/tools/coverage_summary_tool.rb +13 -8
- data/lib/simplecov_mcp/tools/coverage_table_tool.rb +20 -60
- data/lib/simplecov_mcp/tools/coverage_totals_tool.rb +44 -0
- data/lib/simplecov_mcp/tools/help_tool.rb +38 -66
- data/lib/simplecov_mcp/tools/uncovered_lines_tool.rb +13 -8
- data/lib/simplecov_mcp/tools/validate_tool.rb +72 -0
- data/lib/simplecov_mcp/tools/version_tool.rb +7 -14
- data/lib/simplecov_mcp/util.rb +18 -12
- data/lib/simplecov_mcp/version.rb +1 -1
- data/lib/simplecov_mcp.rb +23 -29
- data/spec/all_files_coverage_tool_spec.rb +4 -3
- data/spec/{cli_config_spec.rb → app_config_spec.rb} +31 -26
- data/spec/base_tool_spec.rb +17 -14
- data/spec/cli/show_default_report_spec.rb +2 -2
- data/spec/cli_enumerated_options_spec.rb +31 -9
- data/spec/cli_error_spec.rb +46 -23
- data/spec/cli_format_spec.rb +123 -0
- data/spec/cli_json_options_spec.rb +50 -0
- data/spec/cli_source_spec.rb +11 -63
- data/spec/cli_spec.rb +82 -97
- data/spec/cli_usage_spec.rb +15 -15
- data/spec/commands/base_command_spec.rb +12 -92
- data/spec/commands/command_factory_spec.rb +7 -3
- data/spec/commands/detailed_command_spec.rb +10 -24
- data/spec/commands/list_command_spec.rb +28 -0
- data/spec/commands/raw_command_spec.rb +43 -20
- data/spec/commands/summary_command_spec.rb +10 -23
- data/spec/commands/totals_command_spec.rb +34 -0
- data/spec/commands/uncovered_command_spec.rb +29 -23
- data/spec/commands/validate_command_spec.rb +213 -0
- data/spec/commands/version_command_spec.rb +38 -0
- data/spec/constants_spec.rb +3 -3
- data/spec/coverage_reporter_spec.rb +102 -0
- data/spec/coverage_table_tool_spec.rb +21 -10
- data/spec/coverage_totals_tool_spec.rb +37 -0
- data/spec/error_handler_spec.rb +120 -4
- data/spec/error_mode_spec.rb +18 -22
- data/spec/errors_edge_cases_spec.rb +101 -28
- data/spec/errors_stale_spec.rb +34 -0
- data/spec/file_based_mcp_tools_spec.rb +6 -6
- data/spec/fixtures/project1/lib/bar.rb +2 -0
- data/spec/fixtures/project1/lib/foo.rb +2 -0
- data/spec/help_tool_spec.rb +2 -18
- data/spec/integration_spec.rb +103 -161
- data/spec/logging_fallback_spec.rb +3 -3
- data/spec/mcp_server_integration_spec.rb +1 -1
- data/spec/mcp_server_spec.rb +70 -53
- data/spec/mode_detector_spec.rb +46 -41
- data/spec/model_error_handling_spec.rb +139 -78
- data/spec/model_staleness_spec.rb +13 -13
- data/spec/option_normalizers_spec.rb +111 -112
- data/spec/option_parsers/env_options_parser_spec.rb +25 -37
- data/spec/option_parsers/error_helper_spec.rb +56 -56
- data/spec/path_relativizer_spec.rb +15 -0
- data/spec/presenters/coverage_detailed_presenter_spec.rb +1 -1
- data/spec/presenters/coverage_summary_presenter_spec.rb +1 -1
- data/spec/presenters/coverage_uncovered_presenter_spec.rb +1 -1
- data/spec/presenters/project_coverage_presenter_spec.rb +9 -8
- data/spec/presenters/project_totals_presenter_spec.rb +144 -0
- data/spec/resolvers/coverage_line_resolver_spec.rb +261 -36
- data/spec/resolvers/resultset_path_resolver_spec.rb +13 -8
- data/spec/shared_examples/file_based_mcp_tools.rb +23 -18
- data/spec/shared_examples/formatted_command_examples.rb +64 -0
- data/spec/simple_cov_mcp_module_spec.rb +24 -3
- data/spec/simplecov_mcp/formatters/source_formatter_spec.rb +267 -0
- data/spec/simplecov_mcp/formatters_spec.rb +76 -0
- data/spec/simplecov_mcp/presenters/base_coverage_presenter_spec.rb +79 -0
- data/spec/simplecov_mcp_model_spec.rb +97 -47
- data/spec/simplecov_mcp_opts_spec.rb +42 -39
- data/spec/spec_helper.rb +27 -92
- data/spec/staleness_checker_spec.rb +10 -9
- data/spec/staleness_more_spec.rb +4 -4
- data/spec/support/cli_helpers.rb +22 -0
- data/spec/support/control_flow_helpers.rb +20 -0
- data/spec/support/fake_mcp.rb +40 -0
- data/spec/support/io_helpers.rb +29 -0
- data/spec/support/mcp_helpers.rb +35 -0
- data/spec/support/mcp_runner.rb +10 -8
- data/spec/support/mocking_helpers.rb +30 -0
- data/spec/table_format_spec.rb +70 -0
- data/spec/tools/validate_tool_spec.rb +132 -0
- data/spec/tools_error_handling_spec.rb +34 -48
- data/spec/util_spec.rb +5 -4
- data/spec/version_spec.rb +7 -7
- data/spec/version_tool_spec.rb +20 -22
- metadata +90 -23
- data/docs/BRANCH_ONLY_COVERAGE.md +0 -81
- data/docs/CLI_USAGE.md +0 -637
- data/docs/EXAMPLES.md +0 -430
- data/docs/INSTALLATION.md +0 -352
- data/spec/cli_success_predicate_spec.rb +0 -141
|
@@ -19,7 +19,9 @@ module SimpleCovMcp
|
|
|
19
19
|
begin
|
|
20
20
|
rows = build_source_rows(src, lines_cov, mode: mode, context: context)
|
|
21
21
|
format_source_rows(rows)
|
|
22
|
-
rescue
|
|
22
|
+
rescue ArgumentError
|
|
23
|
+
raise
|
|
24
|
+
rescue
|
|
23
25
|
# If any unexpected formatting/indexing error occurs, avoid crashing the CLI
|
|
24
26
|
'[source not available]'
|
|
25
27
|
end
|
|
@@ -40,8 +42,12 @@ module SimpleCovMcp
|
|
|
40
42
|
def build_source_rows(src_lines, cov_lines, mode:, context: 2)
|
|
41
43
|
# Normalize inputs defensively to avoid type errors in formatting
|
|
42
44
|
coverage_lines = cov_lines || []
|
|
43
|
-
context_line_count =
|
|
44
|
-
|
|
45
|
+
context_line_count = begin
|
|
46
|
+
context.to_i
|
|
47
|
+
rescue
|
|
48
|
+
2
|
|
49
|
+
end
|
|
50
|
+
raise ArgumentError, 'Context lines cannot be negative' if context_line_count.negative?
|
|
45
51
|
|
|
46
52
|
n = src_lines.length
|
|
47
53
|
include_line = Array.new(n, mode == :full)
|
|
@@ -53,7 +59,7 @@ module SimpleCovMcp
|
|
|
53
59
|
end
|
|
54
60
|
|
|
55
61
|
def format_source_rows(rows)
|
|
56
|
-
marker = ->(covered,
|
|
62
|
+
marker = ->(covered, _hits) do
|
|
57
63
|
case covered
|
|
58
64
|
when true then colorize('✓', :green)
|
|
59
65
|
when false then colorize('·', :red)
|
|
@@ -62,12 +68,12 @@ module SimpleCovMcp
|
|
|
62
68
|
end
|
|
63
69
|
|
|
64
70
|
lines = []
|
|
65
|
-
lines <<
|
|
66
|
-
lines <<
|
|
71
|
+
lines << format('%6s %2s | %s', 'Line', ' ', 'Source')
|
|
72
|
+
lines << format('%6s %2s-+-%s', '------', '--', '-' * 60)
|
|
67
73
|
|
|
68
74
|
rows.each do |r|
|
|
69
75
|
m = marker.call(r['covered'], r['hits'])
|
|
70
|
-
lines <<
|
|
76
|
+
lines << format('%6d %2s | %s', r['line'], m, r['code'])
|
|
71
77
|
end
|
|
72
78
|
lines.join("\n")
|
|
73
79
|
end
|
|
@@ -75,29 +81,27 @@ module SimpleCovMcp
|
|
|
75
81
|
def format_detailed_rows(rows)
|
|
76
82
|
# Simple aligned columns: line, hits, covered
|
|
77
83
|
out = []
|
|
78
|
-
out <<
|
|
79
|
-
out <<
|
|
84
|
+
out << format('%6s %6s %7s', 'Line', 'Hits', 'Covered')
|
|
85
|
+
out << format('%6s %6s %7s', '-----', '----', '-------')
|
|
80
86
|
rows.each do |r|
|
|
81
|
-
out <<
|
|
87
|
+
out << format('%6d %6d %5s', r['line'], r['hits'], r['covered'] ? 'yes' : 'no')
|
|
82
88
|
end
|
|
83
89
|
out.join("\n")
|
|
84
90
|
end
|
|
85
91
|
|
|
86
|
-
private
|
|
87
|
-
|
|
88
92
|
attr_reader :color_enabled
|
|
89
93
|
|
|
90
|
-
def fetch_raw(model, path)
|
|
94
|
+
private def fetch_raw(model, path)
|
|
91
95
|
@raw_cache ||= {}
|
|
92
96
|
return @raw_cache[path] if @raw_cache.key?(path)
|
|
93
97
|
|
|
94
98
|
raw = model.raw_for(path)
|
|
95
99
|
@raw_cache[path] = raw
|
|
96
|
-
rescue
|
|
100
|
+
rescue
|
|
97
101
|
nil
|
|
98
102
|
end
|
|
99
103
|
|
|
100
|
-
def mark_uncovered_lines_with_context(coverage_lines, context_line_count, total_lines)
|
|
104
|
+
private def mark_uncovered_lines_with_context(coverage_lines, context_line_count, total_lines)
|
|
101
105
|
include_line = Array.new(total_lines, false)
|
|
102
106
|
misses = find_uncovered_lines(coverage_lines)
|
|
103
107
|
|
|
@@ -108,7 +112,7 @@ module SimpleCovMcp
|
|
|
108
112
|
include_line
|
|
109
113
|
end
|
|
110
114
|
|
|
111
|
-
def find_uncovered_lines(coverage_lines)
|
|
115
|
+
private def find_uncovered_lines(coverage_lines)
|
|
112
116
|
misses = []
|
|
113
117
|
coverage_lines.each_with_index do |hits, i|
|
|
114
118
|
misses << i if !hits.nil? && hits.to_i == 0
|
|
@@ -116,14 +120,14 @@ module SimpleCovMcp
|
|
|
116
120
|
misses
|
|
117
121
|
end
|
|
118
122
|
|
|
119
|
-
def mark_context_lines(include_line, center_line, context_count, total_lines)
|
|
123
|
+
private def mark_context_lines(include_line, center_line, context_count, total_lines)
|
|
120
124
|
start_line = [0, center_line - context_count].max
|
|
121
125
|
end_line = [total_lines - 1, center_line + context_count].min
|
|
122
126
|
|
|
123
127
|
(start_line..end_line).each { |i| include_line[i] = true }
|
|
124
128
|
end
|
|
125
129
|
|
|
126
|
-
def build_row_data(src_lines, coverage_lines, include_line)
|
|
130
|
+
private def build_row_data(src_lines, coverage_lines, include_line)
|
|
127
131
|
out = []
|
|
128
132
|
src_lines.each_with_index do |code, i|
|
|
129
133
|
next unless include_line[i]
|
|
@@ -136,7 +140,7 @@ module SimpleCovMcp
|
|
|
136
140
|
out
|
|
137
141
|
end
|
|
138
142
|
|
|
139
|
-
def colorize(text, color)
|
|
143
|
+
private def colorize(text, color)
|
|
140
144
|
return text unless color_enabled
|
|
141
145
|
|
|
142
146
|
codes = { green: 32, red: 31, dim: 2 }
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module SimpleCovMcp
|
|
6
|
+
module Formatters
|
|
7
|
+
# Maps format symbols to their formatter lambdas
|
|
8
|
+
# Following the rexe pattern for simple, extensible formatting
|
|
9
|
+
FORMATTERS = {
|
|
10
|
+
table: ->(obj) { obj }, # Pass through - table formatting handled elsewhere
|
|
11
|
+
json: lambda(&:to_json),
|
|
12
|
+
pretty_json: ->(obj) { JSON.pretty_generate(obj) },
|
|
13
|
+
yaml: ->(obj) {
|
|
14
|
+
require 'yaml'
|
|
15
|
+
obj.to_yaml
|
|
16
|
+
},
|
|
17
|
+
awesome_print: ->(obj) {
|
|
18
|
+
require 'awesome_print'
|
|
19
|
+
obj.ai
|
|
20
|
+
}
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# Maps format symbols to their required libraries
|
|
24
|
+
# Only loaded when the format is actually used
|
|
25
|
+
FORMAT_REQUIRES = {
|
|
26
|
+
yaml: 'yaml',
|
|
27
|
+
awesome_print: 'awesome_print'
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# Returns the formatter lambda for the given format
|
|
31
|
+
def self.formatter_for(format)
|
|
32
|
+
FORMATTERS[format] or raise ArgumentError, "Unknown format: #{format}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Ensures required libraries are loaded for the given format
|
|
36
|
+
def self.ensure_requirements_for(format)
|
|
37
|
+
requirement = FORMAT_REQUIRES[format]
|
|
38
|
+
require requirement if requirement
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Formats an object using the specified format
|
|
42
|
+
def self.format(obj, format)
|
|
43
|
+
ensure_requirements_for(format)
|
|
44
|
+
formatter_for(format).call(obj)
|
|
45
|
+
rescue LoadError => e
|
|
46
|
+
gem_name = e.message[/-- (\S+)/, 1] || 'required gem'
|
|
47
|
+
raise LoadError, "The #{format} format requires the '#{gem_name}' gem. " \
|
|
48
|
+
"Install it with: gem install #{gem_name}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -17,24 +17,26 @@ module SimpleCovMcp
|
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
# Expose the registered tools so embedders can introspect without booting the server.
|
|
21
|
-
def toolset
|
|
22
|
-
TOOLSET
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
private
|
|
26
|
-
|
|
27
20
|
TOOLSET = [
|
|
28
21
|
Tools::AllFilesCoverageTool,
|
|
29
22
|
Tools::CoverageDetailedTool,
|
|
30
23
|
Tools::CoverageRawTool,
|
|
31
24
|
Tools::CoverageSummaryTool,
|
|
25
|
+
Tools::CoverageTotalsTool,
|
|
32
26
|
Tools::UncoveredLinesTool,
|
|
33
27
|
Tools::CoverageTableTool,
|
|
28
|
+
Tools::ValidateTool,
|
|
34
29
|
Tools::HelpTool,
|
|
35
30
|
Tools::VersionTool
|
|
36
31
|
].freeze
|
|
37
32
|
|
|
33
|
+
# Expose the registered tools so embedders can introspect without booting the server.
|
|
34
|
+
def toolset
|
|
35
|
+
TOOLSET
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
38
40
|
attr_reader :context
|
|
39
41
|
end
|
|
40
42
|
end
|
|
@@ -6,15 +6,16 @@ module SimpleCovMcp
|
|
|
6
6
|
# Centralizes the logic for detecting whether to run in CLI or MCP server mode.
|
|
7
7
|
# This makes the mode detection strategy explicit and testable.
|
|
8
8
|
class ModeDetector
|
|
9
|
-
SUBCOMMANDS = %w[list summary raw uncovered detailed version].freeze
|
|
9
|
+
SUBCOMMANDS = %w[list summary raw uncovered detailed totals validate version].freeze
|
|
10
10
|
|
|
11
11
|
# Reference shared constant to avoid duplication with CoverageCLI
|
|
12
12
|
OPTIONS_EXPECTING_ARGUMENT = Constants::OPTIONS_EXPECTING_ARGUMENT
|
|
13
13
|
|
|
14
|
-
def self.cli_mode?(argv, stdin:
|
|
14
|
+
def self.cli_mode?(argv, stdin: $stdin)
|
|
15
15
|
# 1. Explicit flags that force CLI mode always win
|
|
16
|
-
cli_options = %w[--force-cli -h --help --version]
|
|
17
|
-
return true if (
|
|
16
|
+
cli_options = %w[--force-cli -h --help --version -v]
|
|
17
|
+
return true if argv.intersect?(cli_options)
|
|
18
|
+
|
|
18
19
|
|
|
19
20
|
# 2. Find the first non-option argument
|
|
20
21
|
first_non_option = find_first_non_option(argv)
|
|
@@ -26,7 +27,7 @@ module SimpleCovMcp
|
|
|
26
27
|
stdin.tty?
|
|
27
28
|
end
|
|
28
29
|
|
|
29
|
-
def self.mcp_server_mode?(argv, stdin:
|
|
30
|
+
def self.mcp_server_mode?(argv, stdin: $stdin)
|
|
30
31
|
!cli_mode?(argv, stdin: stdin)
|
|
31
32
|
end
|
|
32
33
|
|
data/lib/simplecov_mcp/model.rb
CHANGED
|
@@ -5,6 +5,7 @@ require 'json'
|
|
|
5
5
|
|
|
6
6
|
require_relative 'util'
|
|
7
7
|
require_relative 'errors'
|
|
8
|
+
require_relative 'error_handler'
|
|
8
9
|
require_relative 'staleness_checker'
|
|
9
10
|
require_relative 'path_relativizer'
|
|
10
11
|
require_relative 'resultset_loader'
|
|
@@ -21,10 +22,10 @@ module SimpleCovMcp
|
|
|
21
22
|
# Params:
|
|
22
23
|
# - root: project root directory (default '.')
|
|
23
24
|
# - resultset: path or directory to .resultset.json
|
|
24
|
-
# - staleness:
|
|
25
|
+
# - staleness: :off or :error (default :off). When :error, raises
|
|
25
26
|
# stale errors if sources are newer than coverage or line counts mismatch.
|
|
26
27
|
# - tracked_globs: only used for all_files project-level staleness.
|
|
27
|
-
def initialize(root: '.', resultset: nil, staleness:
|
|
28
|
+
def initialize(root: '.', resultset: nil, staleness: :off, tracked_globs: nil)
|
|
28
29
|
@root = File.absolute_path(root || '.')
|
|
29
30
|
@resultset = resultset
|
|
30
31
|
@relativizer = PathRelativizer.new(
|
|
@@ -33,47 +34,12 @@ module SimpleCovMcp
|
|
|
33
34
|
array_keys: RELATIVIZER_ARRAY_KEYS
|
|
34
35
|
)
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
rs = CovUtil.find_resultset(@root, resultset: resultset)
|
|
38
|
-
loaded = ResultsetLoader.load(resultset_path: rs)
|
|
39
|
-
coverage_map = loaded.coverage_map or raise CoverageDataError.new("No 'coverage' key found in resultset file: #{rs}")
|
|
40
|
-
|
|
41
|
-
@cov = coverage_map.transform_keys { |k| File.absolute_path(k, @root) }
|
|
42
|
-
@cov_timestamp = loaded.timestamp
|
|
43
|
-
|
|
44
|
-
@checker = StalenessChecker.new(
|
|
45
|
-
root: @root,
|
|
46
|
-
resultset: @resultset,
|
|
47
|
-
mode: staleness,
|
|
48
|
-
tracked_globs: tracked_globs,
|
|
49
|
-
timestamp: @cov_timestamp
|
|
50
|
-
)
|
|
51
|
-
rescue Errno::ENOENT => e
|
|
52
|
-
raise ResultsetNotFoundError.new("Coverage data not found at #{resultset || @root}")
|
|
53
|
-
rescue JSON::ParserError => e
|
|
54
|
-
raise CoverageDataError.new("Invalid coverage data format: #{e.message}")
|
|
55
|
-
rescue Errno::EACCES => e
|
|
56
|
-
raise FilePermissionError.new("Permission denied reading coverage data: #{e.message}")
|
|
57
|
-
rescue TypeError, NoMethodError => e
|
|
58
|
-
# These typically indicate the resultset has an unexpected structure
|
|
59
|
-
raise CoverageDataError.new("Invalid coverage data structure: #{e.message}")
|
|
60
|
-
rescue ArgumentError => e
|
|
61
|
-
# ArgumentError can occur from File.absolute_path or other path operations
|
|
62
|
-
raise CoverageDataError.new("Invalid path in coverage data: #{e.message}")
|
|
63
|
-
rescue RuntimeError => e
|
|
64
|
-
# RuntimeError from find_resultset or other operations
|
|
65
|
-
# Check if it's a resultset not found error
|
|
66
|
-
if e.message.downcase.include?('resultset')
|
|
67
|
-
raise ResultsetNotFoundError.new(e.message)
|
|
68
|
-
else
|
|
69
|
-
raise CoverageDataError.new("Failed to load coverage data: #{e.message}")
|
|
70
|
-
end
|
|
71
|
-
end
|
|
37
|
+
load_coverage_data(resultset, staleness, tracked_globs)
|
|
72
38
|
end
|
|
73
39
|
|
|
74
40
|
# Returns { 'file' => <absolute_path>, 'lines' => [hits|nil,...] }
|
|
75
41
|
def raw_for(path)
|
|
76
|
-
file_abs, coverage_lines =
|
|
42
|
+
file_abs, coverage_lines = coverage_data_for(path)
|
|
77
43
|
{ 'file' => file_abs, 'lines' => coverage_lines }
|
|
78
44
|
end
|
|
79
45
|
|
|
@@ -81,15 +47,15 @@ module SimpleCovMcp
|
|
|
81
47
|
relativizer.relativize(payload)
|
|
82
48
|
end
|
|
83
49
|
|
|
84
|
-
# Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, '
|
|
50
|
+
# Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, 'percentage'=>} }
|
|
85
51
|
def summary_for(path)
|
|
86
|
-
file_abs, coverage_lines =
|
|
52
|
+
file_abs, coverage_lines = coverage_data_for(path)
|
|
87
53
|
{ 'file' => file_abs, 'summary' => CovUtil.summary(coverage_lines) }
|
|
88
54
|
end
|
|
89
55
|
|
|
90
56
|
# Returns { 'file' => <absolute_path>, 'uncovered' => [line,...], 'summary' => {...} }
|
|
91
57
|
def uncovered_for(path)
|
|
92
|
-
file_abs, coverage_lines =
|
|
58
|
+
file_abs, coverage_lines = coverage_data_for(path)
|
|
93
59
|
{
|
|
94
60
|
'file' => file_abs,
|
|
95
61
|
'uncovered' => CovUtil.uncovered(coverage_lines),
|
|
@@ -99,7 +65,7 @@ module SimpleCovMcp
|
|
|
99
65
|
|
|
100
66
|
# Returns { 'file' => <absolute_path>, 'lines' => [{'line'=>,'hits'=>,'covered'=>},...], 'summary' => {...} }
|
|
101
67
|
def detailed_for(path)
|
|
102
|
-
file_abs, coverage_lines =
|
|
68
|
+
file_abs, coverage_lines = coverage_data_for(path)
|
|
103
69
|
{
|
|
104
70
|
'file' => file_abs,
|
|
105
71
|
'lines' => CovUtil.detailed(coverage_lines),
|
|
@@ -108,8 +74,8 @@ module SimpleCovMcp
|
|
|
108
74
|
end
|
|
109
75
|
|
|
110
76
|
# Returns [ { 'file' =>, 'covered' =>, 'total' =>, 'percentage' =>, 'stale' => }, ... ]
|
|
111
|
-
def all_files(sort_order: :
|
|
112
|
-
stale_checker = build_staleness_checker(mode:
|
|
77
|
+
def all_files(sort_order: :descending, check_stale: !@checker.off?, tracked_globs: nil)
|
|
78
|
+
stale_checker = build_staleness_checker(mode: :off, tracked_globs: tracked_globs)
|
|
113
79
|
|
|
114
80
|
rows = @cov.map do |abs_path, _data|
|
|
115
81
|
begin
|
|
@@ -124,7 +90,7 @@ module SimpleCovMcp
|
|
|
124
90
|
'file' => abs_path,
|
|
125
91
|
'covered' => s['covered'],
|
|
126
92
|
'total' => s['total'],
|
|
127
|
-
'percentage' => s['
|
|
93
|
+
'percentage' => s['percentage'],
|
|
128
94
|
'stale' => stale
|
|
129
95
|
}
|
|
130
96
|
end.compact
|
|
@@ -132,24 +98,29 @@ module SimpleCovMcp
|
|
|
132
98
|
rows = filter_rows_by_globs(rows, tracked_globs)
|
|
133
99
|
|
|
134
100
|
if check_stale
|
|
135
|
-
build_staleness_checker(mode:
|
|
101
|
+
build_staleness_checker(mode: :error, tracked_globs: tracked_globs).check_project!(@cov)
|
|
136
102
|
end
|
|
137
103
|
|
|
138
104
|
sort_rows(rows, sort_order: sort_order)
|
|
139
105
|
end
|
|
140
106
|
|
|
107
|
+
def project_totals(tracked_globs: nil, check_stale: !@checker.off?)
|
|
108
|
+
rows = all_files(sort_order: :ascending, check_stale: check_stale,
|
|
109
|
+
tracked_globs: tracked_globs)
|
|
110
|
+
totals_from_rows(rows)
|
|
111
|
+
end
|
|
112
|
+
|
|
141
113
|
def staleness_for(path)
|
|
142
114
|
file_abs = File.absolute_path(path, @root)
|
|
143
115
|
coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
|
|
144
116
|
@checker.stale_for_file?(file_abs, coverage_lines)
|
|
145
|
-
rescue
|
|
146
|
-
|
|
147
|
-
CovUtil.log("Failed to check staleness for #{path}: #{e.message}") rescue nil
|
|
117
|
+
rescue => e
|
|
118
|
+
CovUtil.safe_log("Failed to check staleness for #{path}: #{e.message}")
|
|
148
119
|
false
|
|
149
120
|
end
|
|
150
121
|
|
|
151
122
|
# Returns formatted table string for all files coverage data
|
|
152
|
-
def format_table(rows = nil, sort_order: :
|
|
123
|
+
def format_table(rows = nil, sort_order: :descending, check_stale: !@checker.off?,
|
|
153
124
|
tracked_globs: nil)
|
|
154
125
|
rows = prepare_rows(rows, sort_order: sort_order, check_stale: check_stale,
|
|
155
126
|
tracked_globs: tracked_globs)
|
|
@@ -170,9 +141,28 @@ module SimpleCovMcp
|
|
|
170
141
|
lines.join("\n")
|
|
171
142
|
end
|
|
172
143
|
|
|
173
|
-
private
|
|
144
|
+
private def load_coverage_data(resultset, staleness, tracked_globs)
|
|
145
|
+
rs = CovUtil.find_resultset(@root, resultset: resultset)
|
|
146
|
+
loaded = ResultsetLoader.load(resultset_path: rs)
|
|
147
|
+
coverage_map = loaded.coverage_map or raise(CoverageDataError, "No 'coverage' key found in resultset file: #{rs}")
|
|
148
|
+
|
|
149
|
+
@cov = coverage_map.transform_keys { |k| File.absolute_path(k, @root) }
|
|
150
|
+
@cov_timestamp = loaded.timestamp
|
|
151
|
+
|
|
152
|
+
@checker = StalenessChecker.new(
|
|
153
|
+
root: @root,
|
|
154
|
+
resultset: @resultset,
|
|
155
|
+
mode: staleness,
|
|
156
|
+
tracked_globs: tracked_globs,
|
|
157
|
+
timestamp: @cov_timestamp
|
|
158
|
+
)
|
|
159
|
+
rescue SimpleCovMcp::Error
|
|
160
|
+
raise # Re-raise our own errors as-is
|
|
161
|
+
rescue => e
|
|
162
|
+
raise ErrorHandler.new.convert_standard_error(e, context: :coverage_loading)
|
|
163
|
+
end
|
|
174
164
|
|
|
175
|
-
def build_staleness_checker(mode:, tracked_globs:)
|
|
165
|
+
private def build_staleness_checker(mode:, tracked_globs:)
|
|
176
166
|
StalenessChecker.new(
|
|
177
167
|
root: @root,
|
|
178
168
|
resultset: @resultset,
|
|
@@ -182,7 +172,7 @@ module SimpleCovMcp
|
|
|
182
172
|
)
|
|
183
173
|
end
|
|
184
174
|
|
|
185
|
-
def prepare_rows(rows, sort_order:, check_stale:, tracked_globs:)
|
|
175
|
+
private def prepare_rows(rows, sort_order:, check_stale:, tracked_globs:)
|
|
186
176
|
if rows.nil?
|
|
187
177
|
all_files(sort_order: sort_order, check_stale: check_stale, tracked_globs: tracked_globs)
|
|
188
178
|
else
|
|
@@ -191,7 +181,7 @@ module SimpleCovMcp
|
|
|
191
181
|
end
|
|
192
182
|
end
|
|
193
183
|
|
|
194
|
-
def sort_rows(rows, sort_order: :
|
|
184
|
+
private def sort_rows(rows, sort_order: :descending)
|
|
195
185
|
rows.sort do |a, b|
|
|
196
186
|
pct_cmp = (sort_order == :descending) \
|
|
197
187
|
? (b['percentage'] <=> a['percentage'])
|
|
@@ -200,7 +190,7 @@ module SimpleCovMcp
|
|
|
200
190
|
end
|
|
201
191
|
end
|
|
202
192
|
|
|
203
|
-
def compute_table_widths(rows)
|
|
193
|
+
private def compute_table_widths(rows)
|
|
204
194
|
max_file_length = rows.map { |f| f['file'].length }.max.to_i
|
|
205
195
|
file_width = [max_file_length, 'File'.length].max + 2
|
|
206
196
|
pct_width = 8
|
|
@@ -218,7 +208,7 @@ module SimpleCovMcp
|
|
|
218
208
|
}
|
|
219
209
|
end
|
|
220
210
|
|
|
221
|
-
def border_line(widths, left, middle, right)
|
|
211
|
+
private def border_line(widths, left, middle, right)
|
|
222
212
|
h_line = ->(col_width) { '─' * (col_width + 2) }
|
|
223
213
|
left +
|
|
224
214
|
h_line.call(widths[:file]) +
|
|
@@ -229,16 +219,16 @@ module SimpleCovMcp
|
|
|
229
219
|
right
|
|
230
220
|
end
|
|
231
221
|
|
|
232
|
-
def header_row(widths)
|
|
233
|
-
|
|
222
|
+
private def header_row(widths)
|
|
223
|
+
format(
|
|
234
224
|
"│ %-#{widths[:file]}s │ %#{widths[:pct]}s │ %#{widths[:covered]}s │ %#{widths[:total]}s │ %#{widths[:stale]}s │",
|
|
235
225
|
'File', ' %', 'Covered', 'Total', 'Stale'.center(widths[:stale])
|
|
236
226
|
)
|
|
237
227
|
end
|
|
238
228
|
|
|
239
|
-
def data_row(file_data, widths)
|
|
229
|
+
private def data_row(file_data, widths)
|
|
240
230
|
stale_text_str = file_data['stale'] ? file_data['stale'].to_s : ''
|
|
241
|
-
|
|
231
|
+
format(
|
|
242
232
|
"│ %-#{widths[:file]}s │ %#{widths[:pct] - 1}.2f%% │ %#{widths[:covered]}d │ %#{widths[:total]}d │ %#{widths[:stale]}s │",
|
|
243
233
|
file_data['file'],
|
|
244
234
|
file_data['percentage'],
|
|
@@ -248,58 +238,102 @@ module SimpleCovMcp
|
|
|
248
238
|
)
|
|
249
239
|
end
|
|
250
240
|
|
|
251
|
-
def summary_counts(rows)
|
|
241
|
+
private def summary_counts(rows)
|
|
252
242
|
total = rows.length
|
|
253
243
|
stale_count = rows.count { |f| f['stale'] }
|
|
254
244
|
ok_count = total - stale_count
|
|
255
245
|
"Files: total #{total}, ok #{ok_count}, stale #{stale_count}"
|
|
256
246
|
end
|
|
257
247
|
|
|
258
|
-
|
|
248
|
+
# Filters coverage rows to only include files matching the given glob patterns.
|
|
249
|
+
#
|
|
250
|
+
# @param rows [Array<Hash>] coverage rows with 'file' keys containing absolute paths
|
|
251
|
+
# @param tracked_globs [Array<String>, String, nil] glob patterns to match against
|
|
252
|
+
# @return [Array<Hash>] rows whose files match at least one pattern (or all rows if no patterns)
|
|
253
|
+
private def filter_rows_by_globs(rows, tracked_globs)
|
|
259
254
|
patterns = Array(tracked_globs).compact.map(&:to_s).reject(&:empty?)
|
|
260
255
|
return rows if patterns.empty?
|
|
261
256
|
|
|
262
|
-
|
|
263
|
-
|
|
257
|
+
absolute_patterns = patterns.map { |p| absolutize_pattern(p) }
|
|
258
|
+
rows.select { |row| matches_any_pattern?(row['file'], absolute_patterns) }
|
|
259
|
+
end
|
|
264
260
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
261
|
+
# Converts a relative pattern to absolute by joining with root.
|
|
262
|
+
# Absolute patterns are returned unchanged.
|
|
263
|
+
#
|
|
264
|
+
# @param pattern [String] glob pattern (e.g., "lib/**/*.rb")
|
|
265
|
+
# @return [String] absolute pattern
|
|
266
|
+
private def absolutize_pattern(pattern)
|
|
267
|
+
absolute_pattern?(pattern) ? pattern : File.join(@root, pattern)
|
|
268
|
+
end
|
|
272
269
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
270
|
+
# Checks if a pattern is absolute, handling both Unix and Windows-style paths.
|
|
271
|
+
# On Unix, Pathname won't recognize "C:/" as absolute, so we check explicitly.
|
|
272
|
+
#
|
|
273
|
+
# @param pattern [String] glob pattern
|
|
274
|
+
# @return [Boolean] true if pattern is absolute
|
|
275
|
+
private def absolute_pattern?(pattern)
|
|
276
|
+
Pathname.new(pattern).absolute? || pattern.match?(/\A[A-Za-z]:/)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Tests if a file path matches any of the given absolute glob patterns.
|
|
280
|
+
# Uses File.fnmatch? for pure string matching without filesystem access,
|
|
281
|
+
# which is faster and works for paths that may no longer exist on disk.
|
|
282
|
+
#
|
|
283
|
+
# @param abs_path [String] absolute file path to test
|
|
284
|
+
# @param patterns [Array<String>] absolute glob patterns
|
|
285
|
+
# @return [Boolean] true if the path matches at least one pattern
|
|
286
|
+
private def matches_any_pattern?(abs_path, patterns)
|
|
287
|
+
flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
288
|
+
patterns.any? { |pattern| File.fnmatch?(pattern, abs_path, flags) }
|
|
278
289
|
end
|
|
279
290
|
|
|
280
|
-
|
|
291
|
+
# Retrieves coverage data for a file path.
|
|
292
|
+
# Converts the path to absolute form and performs staleness checking if enabled.
|
|
293
|
+
#
|
|
294
|
+
# @param path [String] relative or absolute file path
|
|
295
|
+
# @return [Array(String, Array)] tuple of [absolute_path, coverage_lines]
|
|
296
|
+
# @raise [FileError] if no coverage data exists for the file
|
|
297
|
+
# @raise [FileNotFoundError] if the file does not exist
|
|
298
|
+
# @raise [CoverageDataStaleError] if staleness checking is enabled and data is stale
|
|
299
|
+
private def coverage_data_for(path)
|
|
281
300
|
file_abs = File.absolute_path(path, @root)
|
|
282
301
|
begin
|
|
283
302
|
coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
|
|
284
|
-
rescue RuntimeError
|
|
285
|
-
raise FileError
|
|
303
|
+
rescue RuntimeError
|
|
304
|
+
raise FileError, "No coverage data found for file: #{path}"
|
|
286
305
|
end
|
|
287
306
|
@checker.check_file!(file_abs, coverage_lines) unless @checker.off?
|
|
288
307
|
if coverage_lines.nil?
|
|
289
|
-
raise FileError
|
|
308
|
+
raise FileError, "No coverage data found for file: #{path}"
|
|
290
309
|
end
|
|
291
310
|
|
|
292
311
|
[file_abs, coverage_lines]
|
|
293
|
-
rescue Errno::ENOENT
|
|
294
|
-
raise FileNotFoundError
|
|
312
|
+
rescue Errno::ENOENT
|
|
313
|
+
raise FileNotFoundError, "File not found: #{path}"
|
|
295
314
|
end
|
|
296
315
|
|
|
297
|
-
|
|
316
|
+
private def totals_from_rows(rows)
|
|
317
|
+
covered = rows.sum { |row| row['covered'].to_i }
|
|
318
|
+
total = rows.sum { |row| row['total'].to_i }
|
|
319
|
+
uncovered = total - covered
|
|
320
|
+
percentage = total.zero? ? 100.0 : ((covered.to_f * 100.0 / total) * 100).round / 100.0
|
|
321
|
+
stale_count = rows.count { |row| row['stale'] }
|
|
322
|
+
files_total = rows.length
|
|
298
323
|
|
|
299
|
-
|
|
300
|
-
|
|
324
|
+
{
|
|
325
|
+
'lines' => {
|
|
326
|
+
'covered' => covered,
|
|
327
|
+
'uncovered' => uncovered,
|
|
328
|
+
'total' => total
|
|
329
|
+
},
|
|
330
|
+
'percentage' => percentage,
|
|
331
|
+
'files' => {
|
|
332
|
+
'total' => files_total,
|
|
333
|
+
'ok' => files_total - stale_count,
|
|
334
|
+
'stale' => stale_count
|
|
335
|
+
}
|
|
336
|
+
}
|
|
301
337
|
end
|
|
302
|
-
|
|
303
|
-
# Detailed stale message construction moved to CoverageDataStaleError
|
|
304
338
|
end
|
|
305
339
|
end
|