simplecov-mcp 1.0.0 → 2.0.0
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 +32 -20
- 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 -83
- data/lib/simplecov_mcp/option_normalizers.rb +39 -18
- data/lib/simplecov_mcp/option_parser_builder.rb +82 -65
- 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 +114 -170
- 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 +141 -82
- 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 +99 -49
- 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,42 +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 FileError.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
|
-
raise CoverageDataError.new("Failed to load coverage data: #{e.message}")
|
|
66
|
-
end
|
|
37
|
+
load_coverage_data(resultset, staleness, tracked_globs)
|
|
67
38
|
end
|
|
68
39
|
|
|
69
40
|
# Returns { 'file' => <absolute_path>, 'lines' => [hits|nil,...] }
|
|
70
41
|
def raw_for(path)
|
|
71
|
-
file_abs, coverage_lines =
|
|
42
|
+
file_abs, coverage_lines = coverage_data_for(path)
|
|
72
43
|
{ 'file' => file_abs, 'lines' => coverage_lines }
|
|
73
44
|
end
|
|
74
45
|
|
|
@@ -76,15 +47,15 @@ module SimpleCovMcp
|
|
|
76
47
|
relativizer.relativize(payload)
|
|
77
48
|
end
|
|
78
49
|
|
|
79
|
-
# Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, '
|
|
50
|
+
# Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, 'percentage'=>} }
|
|
80
51
|
def summary_for(path)
|
|
81
|
-
file_abs, coverage_lines =
|
|
52
|
+
file_abs, coverage_lines = coverage_data_for(path)
|
|
82
53
|
{ 'file' => file_abs, 'summary' => CovUtil.summary(coverage_lines) }
|
|
83
54
|
end
|
|
84
55
|
|
|
85
56
|
# Returns { 'file' => <absolute_path>, 'uncovered' => [line,...], 'summary' => {...} }
|
|
86
57
|
def uncovered_for(path)
|
|
87
|
-
file_abs, coverage_lines =
|
|
58
|
+
file_abs, coverage_lines = coverage_data_for(path)
|
|
88
59
|
{
|
|
89
60
|
'file' => file_abs,
|
|
90
61
|
'uncovered' => CovUtil.uncovered(coverage_lines),
|
|
@@ -94,7 +65,7 @@ module SimpleCovMcp
|
|
|
94
65
|
|
|
95
66
|
# Returns { 'file' => <absolute_path>, 'lines' => [{'line'=>,'hits'=>,'covered'=>},...], 'summary' => {...} }
|
|
96
67
|
def detailed_for(path)
|
|
97
|
-
file_abs, coverage_lines =
|
|
68
|
+
file_abs, coverage_lines = coverage_data_for(path)
|
|
98
69
|
{
|
|
99
70
|
'file' => file_abs,
|
|
100
71
|
'lines' => CovUtil.detailed(coverage_lines),
|
|
@@ -103,8 +74,8 @@ module SimpleCovMcp
|
|
|
103
74
|
end
|
|
104
75
|
|
|
105
76
|
# Returns [ { 'file' =>, 'covered' =>, 'total' =>, 'percentage' =>, 'stale' => }, ... ]
|
|
106
|
-
def all_files(sort_order: :
|
|
107
|
-
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)
|
|
108
79
|
|
|
109
80
|
rows = @cov.map do |abs_path, _data|
|
|
110
81
|
begin
|
|
@@ -119,7 +90,7 @@ module SimpleCovMcp
|
|
|
119
90
|
'file' => abs_path,
|
|
120
91
|
'covered' => s['covered'],
|
|
121
92
|
'total' => s['total'],
|
|
122
|
-
'percentage' => s['
|
|
93
|
+
'percentage' => s['percentage'],
|
|
123
94
|
'stale' => stale
|
|
124
95
|
}
|
|
125
96
|
end.compact
|
|
@@ -127,24 +98,29 @@ module SimpleCovMcp
|
|
|
127
98
|
rows = filter_rows_by_globs(rows, tracked_globs)
|
|
128
99
|
|
|
129
100
|
if check_stale
|
|
130
|
-
build_staleness_checker(mode:
|
|
101
|
+
build_staleness_checker(mode: :error, tracked_globs: tracked_globs).check_project!(@cov)
|
|
131
102
|
end
|
|
132
103
|
|
|
133
104
|
sort_rows(rows, sort_order: sort_order)
|
|
134
105
|
end
|
|
135
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
|
+
|
|
136
113
|
def staleness_for(path)
|
|
137
114
|
file_abs = File.absolute_path(path, @root)
|
|
138
115
|
coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
|
|
139
116
|
@checker.stale_for_file?(file_abs, coverage_lines)
|
|
140
|
-
rescue
|
|
141
|
-
|
|
142
|
-
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}")
|
|
143
119
|
false
|
|
144
120
|
end
|
|
145
121
|
|
|
146
122
|
# Returns formatted table string for all files coverage data
|
|
147
|
-
def format_table(rows = nil, sort_order: :
|
|
123
|
+
def format_table(rows = nil, sort_order: :descending, check_stale: !@checker.off?,
|
|
148
124
|
tracked_globs: nil)
|
|
149
125
|
rows = prepare_rows(rows, sort_order: sort_order, check_stale: check_stale,
|
|
150
126
|
tracked_globs: tracked_globs)
|
|
@@ -165,9 +141,28 @@ module SimpleCovMcp
|
|
|
165
141
|
lines.join("\n")
|
|
166
142
|
end
|
|
167
143
|
|
|
168
|
-
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
|
|
169
164
|
|
|
170
|
-
def build_staleness_checker(mode:, tracked_globs:)
|
|
165
|
+
private def build_staleness_checker(mode:, tracked_globs:)
|
|
171
166
|
StalenessChecker.new(
|
|
172
167
|
root: @root,
|
|
173
168
|
resultset: @resultset,
|
|
@@ -177,7 +172,7 @@ module SimpleCovMcp
|
|
|
177
172
|
)
|
|
178
173
|
end
|
|
179
174
|
|
|
180
|
-
def prepare_rows(rows, sort_order:, check_stale:, tracked_globs:)
|
|
175
|
+
private def prepare_rows(rows, sort_order:, check_stale:, tracked_globs:)
|
|
181
176
|
if rows.nil?
|
|
182
177
|
all_files(sort_order: sort_order, check_stale: check_stale, tracked_globs: tracked_globs)
|
|
183
178
|
else
|
|
@@ -186,7 +181,7 @@ module SimpleCovMcp
|
|
|
186
181
|
end
|
|
187
182
|
end
|
|
188
183
|
|
|
189
|
-
def sort_rows(rows, sort_order: :
|
|
184
|
+
private def sort_rows(rows, sort_order: :descending)
|
|
190
185
|
rows.sort do |a, b|
|
|
191
186
|
pct_cmp = (sort_order == :descending) \
|
|
192
187
|
? (b['percentage'] <=> a['percentage'])
|
|
@@ -195,7 +190,7 @@ module SimpleCovMcp
|
|
|
195
190
|
end
|
|
196
191
|
end
|
|
197
192
|
|
|
198
|
-
def compute_table_widths(rows)
|
|
193
|
+
private def compute_table_widths(rows)
|
|
199
194
|
max_file_length = rows.map { |f| f['file'].length }.max.to_i
|
|
200
195
|
file_width = [max_file_length, 'File'.length].max + 2
|
|
201
196
|
pct_width = 8
|
|
@@ -213,7 +208,7 @@ module SimpleCovMcp
|
|
|
213
208
|
}
|
|
214
209
|
end
|
|
215
210
|
|
|
216
|
-
def border_line(widths, left, middle, right)
|
|
211
|
+
private def border_line(widths, left, middle, right)
|
|
217
212
|
h_line = ->(col_width) { '─' * (col_width + 2) }
|
|
218
213
|
left +
|
|
219
214
|
h_line.call(widths[:file]) +
|
|
@@ -224,16 +219,16 @@ module SimpleCovMcp
|
|
|
224
219
|
right
|
|
225
220
|
end
|
|
226
221
|
|
|
227
|
-
def header_row(widths)
|
|
228
|
-
|
|
222
|
+
private def header_row(widths)
|
|
223
|
+
format(
|
|
229
224
|
"│ %-#{widths[:file]}s │ %#{widths[:pct]}s │ %#{widths[:covered]}s │ %#{widths[:total]}s │ %#{widths[:stale]}s │",
|
|
230
225
|
'File', ' %', 'Covered', 'Total', 'Stale'.center(widths[:stale])
|
|
231
226
|
)
|
|
232
227
|
end
|
|
233
228
|
|
|
234
|
-
def data_row(file_data, widths)
|
|
229
|
+
private def data_row(file_data, widths)
|
|
235
230
|
stale_text_str = file_data['stale'] ? file_data['stale'].to_s : ''
|
|
236
|
-
|
|
231
|
+
format(
|
|
237
232
|
"│ %-#{widths[:file]}s │ %#{widths[:pct] - 1}.2f%% │ %#{widths[:covered]}d │ %#{widths[:total]}d │ %#{widths[:stale]}s │",
|
|
238
233
|
file_data['file'],
|
|
239
234
|
file_data['percentage'],
|
|
@@ -243,58 +238,102 @@ module SimpleCovMcp
|
|
|
243
238
|
)
|
|
244
239
|
end
|
|
245
240
|
|
|
246
|
-
def summary_counts(rows)
|
|
241
|
+
private def summary_counts(rows)
|
|
247
242
|
total = rows.length
|
|
248
243
|
stale_count = rows.count { |f| f['stale'] }
|
|
249
244
|
ok_count = total - stale_count
|
|
250
245
|
"Files: total #{total}, ok #{ok_count}, stale #{stale_count}"
|
|
251
246
|
end
|
|
252
247
|
|
|
253
|
-
|
|
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)
|
|
254
254
|
patterns = Array(tracked_globs).compact.map(&:to_s).reject(&:empty?)
|
|
255
255
|
return rows if patterns.empty?
|
|
256
256
|
|
|
257
|
-
|
|
258
|
-
|
|
257
|
+
absolute_patterns = patterns.map { |p| absolutize_pattern(p) }
|
|
258
|
+
rows.select { |row| matches_any_pattern?(row['file'], absolute_patterns) }
|
|
259
|
+
end
|
|
259
260
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
267
269
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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]:/)
|
|
273
277
|
end
|
|
274
278
|
|
|
275
|
-
|
|
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) }
|
|
289
|
+
end
|
|
290
|
+
|
|
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)
|
|
276
300
|
file_abs = File.absolute_path(path, @root)
|
|
277
301
|
begin
|
|
278
302
|
coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
|
|
279
|
-
rescue RuntimeError
|
|
280
|
-
raise FileError
|
|
303
|
+
rescue RuntimeError
|
|
304
|
+
raise FileError, "No coverage data found for file: #{path}"
|
|
281
305
|
end
|
|
282
306
|
@checker.check_file!(file_abs, coverage_lines) unless @checker.off?
|
|
283
307
|
if coverage_lines.nil?
|
|
284
|
-
raise FileError
|
|
308
|
+
raise FileError, "No coverage data found for file: #{path}"
|
|
285
309
|
end
|
|
286
310
|
|
|
287
311
|
[file_abs, coverage_lines]
|
|
288
|
-
rescue Errno::ENOENT
|
|
289
|
-
raise FileNotFoundError
|
|
312
|
+
rescue Errno::ENOENT
|
|
313
|
+
raise FileNotFoundError, "File not found: #{path}"
|
|
290
314
|
end
|
|
291
315
|
|
|
292
|
-
|
|
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
|
|
293
323
|
|
|
294
|
-
|
|
295
|
-
|
|
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
|
+
}
|
|
296
337
|
end
|
|
297
|
-
|
|
298
|
-
# Detailed stale message construction moved to CoverageDataStaleError
|
|
299
338
|
end
|
|
300
339
|
end
|