cov-loupe 3.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +329 -0
- data/docs/dev/ARCHITECTURE.md +80 -0
- data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
- data/docs/dev/DEVELOPMENT.md +83 -0
- data/docs/dev/README.md +10 -0
- data/docs/dev/RELEASING.md +146 -0
- data/docs/dev/arch-decisions/001-x-arch-decision.md +95 -0
- data/docs/dev/arch-decisions/002-x-arch-decision.md +159 -0
- data/docs/dev/arch-decisions/003-x-arch-decision.md +165 -0
- data/docs/dev/arch-decisions/004-x-arch-decision.md +203 -0
- data/docs/dev/arch-decisions/005-x-arch-decision.md +189 -0
- data/docs/dev/arch-decisions/README.md +60 -0
- data/docs/dev/presentations/cov-loupe-presentation.md +255 -0
- data/docs/fixtures/demo_project/README.md +9 -0
- data/docs/user/ADVANCED_USAGE.md +777 -0
- data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
- data/docs/user/CLI_USAGE.md +750 -0
- data/docs/user/ERROR_HANDLING.md +93 -0
- data/docs/user/EXAMPLES.md +588 -0
- data/docs/user/INSTALLATION.md +130 -0
- data/docs/user/LIBRARY_API.md +693 -0
- data/docs/user/MCP_INTEGRATION.md +490 -0
- data/docs/user/README.md +14 -0
- data/docs/user/TROUBLESHOOTING.md +197 -0
- data/docs/user/V2-BREAKING-CHANGES.md +472 -0
- data/exe/cov-loupe +23 -0
- data/lib/cov_loupe/app_config.rb +56 -0
- data/lib/cov_loupe/app_context.rb +26 -0
- data/lib/cov_loupe/base_tool.rb +102 -0
- data/lib/cov_loupe/cli.rb +178 -0
- data/lib/cov_loupe/commands/base_command.rb +67 -0
- data/lib/cov_loupe/commands/command_factory.rb +45 -0
- data/lib/cov_loupe/commands/detailed_command.rb +38 -0
- data/lib/cov_loupe/commands/list_command.rb +13 -0
- data/lib/cov_loupe/commands/raw_command.rb +38 -0
- data/lib/cov_loupe/commands/summary_command.rb +41 -0
- data/lib/cov_loupe/commands/totals_command.rb +53 -0
- data/lib/cov_loupe/commands/uncovered_command.rb +45 -0
- data/lib/cov_loupe/commands/validate_command.rb +60 -0
- data/lib/cov_loupe/commands/version_command.rb +33 -0
- data/lib/cov_loupe/config_parser.rb +32 -0
- data/lib/cov_loupe/constants.rb +22 -0
- data/lib/cov_loupe/coverage_reporter.rb +31 -0
- data/lib/cov_loupe/error_handler.rb +165 -0
- data/lib/cov_loupe/error_handler_factory.rb +31 -0
- data/lib/cov_loupe/errors.rb +191 -0
- data/lib/cov_loupe/formatters/source_formatter.rb +152 -0
- data/lib/cov_loupe/formatters.rb +51 -0
- data/lib/cov_loupe/mcp_server.rb +42 -0
- data/lib/cov_loupe/mode_detector.rb +56 -0
- data/lib/cov_loupe/model.rb +339 -0
- data/lib/cov_loupe/option_normalizers.rb +113 -0
- data/lib/cov_loupe/option_parser_builder.rb +147 -0
- data/lib/cov_loupe/option_parsers/env_options_parser.rb +48 -0
- data/lib/cov_loupe/option_parsers/error_helper.rb +110 -0
- data/lib/cov_loupe/path_relativizer.rb +64 -0
- data/lib/cov_loupe/predicate_evaluator.rb +72 -0
- data/lib/cov_loupe/presenters/base_coverage_presenter.rb +42 -0
- data/lib/cov_loupe/presenters/coverage_detailed_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/coverage_raw_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/coverage_summary_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/coverage_uncovered_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/project_coverage_presenter.rb +50 -0
- data/lib/cov_loupe/presenters/project_totals_presenter.rb +27 -0
- data/lib/cov_loupe/resolvers/coverage_line_resolver.rb +122 -0
- data/lib/cov_loupe/resolvers/resolver_factory.rb +28 -0
- data/lib/cov_loupe/resolvers/resultset_path_resolver.rb +76 -0
- data/lib/cov_loupe/resultset_loader.rb +131 -0
- data/lib/cov_loupe/staleness_checker.rb +247 -0
- data/lib/cov_loupe/table_formatter.rb +64 -0
- data/lib/cov_loupe/tools/all_files_coverage_tool.rb +51 -0
- data/lib/cov_loupe/tools/coverage_detailed_tool.rb +35 -0
- data/lib/cov_loupe/tools/coverage_raw_tool.rb +34 -0
- data/lib/cov_loupe/tools/coverage_summary_tool.rb +34 -0
- data/lib/cov_loupe/tools/coverage_table_tool.rb +50 -0
- data/lib/cov_loupe/tools/coverage_totals_tool.rb +44 -0
- data/lib/cov_loupe/tools/help_tool.rb +115 -0
- data/lib/cov_loupe/tools/uncovered_lines_tool.rb +34 -0
- data/lib/cov_loupe/tools/validate_tool.rb +72 -0
- data/lib/cov_loupe/tools/version_tool.rb +32 -0
- data/lib/cov_loupe/util.rb +88 -0
- data/lib/cov_loupe/version.rb +5 -0
- data/lib/cov_loupe.rb +140 -0
- data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
- data/spec/TIMESTAMPS.md +48 -0
- data/spec/all_files_coverage_tool_spec.rb +53 -0
- data/spec/app_config_spec.rb +142 -0
- data/spec/base_tool_spec.rb +62 -0
- data/spec/cli/show_default_report_spec.rb +33 -0
- data/spec/cli_enumerated_options_spec.rb +90 -0
- data/spec/cli_error_spec.rb +184 -0
- data/spec/cli_format_spec.rb +123 -0
- data/spec/cli_json_options_spec.rb +50 -0
- data/spec/cli_source_spec.rb +44 -0
- data/spec/cli_spec.rb +192 -0
- data/spec/cli_table_spec.rb +28 -0
- data/spec/cli_usage_spec.rb +42 -0
- data/spec/commands/base_command_spec.rb +107 -0
- data/spec/commands/command_factory_spec.rb +76 -0
- data/spec/commands/detailed_command_spec.rb +34 -0
- data/spec/commands/list_command_spec.rb +28 -0
- data/spec/commands/raw_command_spec.rb +69 -0
- data/spec/commands/summary_command_spec.rb +34 -0
- data/spec/commands/totals_command_spec.rb +34 -0
- data/spec/commands/uncovered_command_spec.rb +55 -0
- data/spec/commands/validate_command_spec.rb +213 -0
- data/spec/commands/version_command_spec.rb +38 -0
- data/spec/constants_spec.rb +61 -0
- data/spec/cov_loupe/formatters/source_formatter_spec.rb +267 -0
- data/spec/cov_loupe/formatters_spec.rb +76 -0
- data/spec/cov_loupe/presenters/base_coverage_presenter_spec.rb +79 -0
- data/spec/cov_loupe_model_spec.rb +454 -0
- data/spec/cov_loupe_module_spec.rb +37 -0
- data/spec/cov_loupe_opts_spec.rb +185 -0
- data/spec/coverage_reporter_spec.rb +102 -0
- data/spec/coverage_table_tool_spec.rb +59 -0
- data/spec/coverage_totals_tool_spec.rb +37 -0
- data/spec/error_handler_spec.rb +197 -0
- data/spec/error_mode_spec.rb +139 -0
- data/spec/errors_edge_cases_spec.rb +312 -0
- data/spec/errors_stale_spec.rb +83 -0
- data/spec/file_based_mcp_tools_spec.rb +99 -0
- data/spec/fixtures/project1/lib/bar.rb +5 -0
- data/spec/fixtures/project1/lib/foo.rb +6 -0
- data/spec/help_tool_spec.rb +26 -0
- data/spec/integration_spec.rb +789 -0
- data/spec/logging_fallback_spec.rb +128 -0
- data/spec/mcp_logging_spec.rb +44 -0
- data/spec/mcp_server_integration_spec.rb +23 -0
- data/spec/mcp_server_spec.rb +106 -0
- data/spec/mode_detector_spec.rb +153 -0
- data/spec/model_error_handling_spec.rb +269 -0
- data/spec/model_staleness_spec.rb +79 -0
- data/spec/option_normalizers_spec.rb +203 -0
- data/spec/option_parsers/env_options_parser_spec.rb +221 -0
- data/spec/option_parsers/error_helper_spec.rb +222 -0
- data/spec/path_relativizer_spec.rb +98 -0
- data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
- data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
- data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
- data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
- data/spec/presenters/project_coverage_presenter_spec.rb +87 -0
- data/spec/presenters/project_totals_presenter_spec.rb +144 -0
- data/spec/resolvers/coverage_line_resolver_spec.rb +282 -0
- data/spec/resolvers/resolver_factory_spec.rb +61 -0
- data/spec/resolvers/resultset_path_resolver_spec.rb +60 -0
- data/spec/resultset_loader_spec.rb +167 -0
- data/spec/shared_examples/README.md +115 -0
- data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
- data/spec/shared_examples/file_based_mcp_tools.rb +179 -0
- data/spec/shared_examples/formatted_command_examples.rb +64 -0
- data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
- data/spec/spec_helper.rb +127 -0
- data/spec/staleness_checker_spec.rb +374 -0
- data/spec/staleness_more_spec.rb +42 -0
- 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 +66 -0
- 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 +130 -0
- data/spec/util_spec.rb +154 -0
- data/spec/version_spec.rb +123 -0
- data/spec/version_tool_spec.rb +141 -0
- metadata +290 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CovLoupe
|
|
4
|
+
# Reports files with coverage below a specified threshold.
|
|
5
|
+
# Useful for displaying low coverage files after test runs.
|
|
6
|
+
#
|
|
7
|
+
# @example Basic usage in spec_helper.rb
|
|
8
|
+
# SimpleCov.at_exit do
|
|
9
|
+
# SimpleCov.result.format!
|
|
10
|
+
# report = CovLoupe::CoverageReporter.report(threshold: 80, count: 5)
|
|
11
|
+
# puts report if report
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
module CoverageReporter
|
|
15
|
+
module_function def report(threshold: 80, count: 5, model: nil)
|
|
16
|
+
model ||= CoverageModel.new
|
|
17
|
+
file_list = model.all_files(sort_order: :ascending)
|
|
18
|
+
.select { |f| f['percentage'] < threshold }
|
|
19
|
+
.first(count)
|
|
20
|
+
file_list = model.relativize(file_list)
|
|
21
|
+
|
|
22
|
+
return nil if file_list.empty?
|
|
23
|
+
|
|
24
|
+
lines = ["\nLowest coverage files (< #{threshold}%):"]
|
|
25
|
+
file_list.each do |f|
|
|
26
|
+
lines << format(' %5.1f%% %s', f['percentage'], f['file'])
|
|
27
|
+
end
|
|
28
|
+
lines.join("\n")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'errors'
|
|
5
|
+
require_relative 'util'
|
|
6
|
+
|
|
7
|
+
module CovLoupe
|
|
8
|
+
# Handles error reporting and logging with configurable behavior
|
|
9
|
+
class ErrorHandler
|
|
10
|
+
attr_accessor :error_mode, :logger
|
|
11
|
+
|
|
12
|
+
VALID_ERROR_MODES = [:off, :log, :debug].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(error_mode: :log, logger: nil)
|
|
15
|
+
unless VALID_ERROR_MODES.include?(error_mode)
|
|
16
|
+
raise ArgumentError, "Invalid error_mode: #{error_mode.inspect}. Valid modes: #{VALID_ERROR_MODES.inspect}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@error_mode = error_mode
|
|
20
|
+
@logger = logger
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def log_errors?
|
|
24
|
+
error_mode != :off
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def show_stack_traces?
|
|
28
|
+
error_mode == :debug
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Handle an error with appropriate logging and re-raising behavior
|
|
32
|
+
def handle_error(error, context: nil, reraise: true)
|
|
33
|
+
log_error(error, context)
|
|
34
|
+
if reraise
|
|
35
|
+
raise error.is_a?(CovLoupe::Error) ? error : convert_standard_error(error)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Convert standard Ruby errors to user-friendly custom errors.
|
|
40
|
+
# @param error [Exception] the error to convert
|
|
41
|
+
# @param context [Symbol] :general (default) or :coverage_loading for context-specific messages
|
|
42
|
+
def convert_standard_error(error, context: :general)
|
|
43
|
+
case error
|
|
44
|
+
when Errno::ENOENT
|
|
45
|
+
convert_enoent(error, context)
|
|
46
|
+
when Errno::EACCES
|
|
47
|
+
convert_eacces(error, context)
|
|
48
|
+
when Errno::EISDIR
|
|
49
|
+
filename = extract_filename(error.message)
|
|
50
|
+
NotAFileError.new("Expected file but found directory: #{filename}", error)
|
|
51
|
+
when JSON::ParserError
|
|
52
|
+
CoverageDataError.new("Invalid coverage data format: #{error.message}", error)
|
|
53
|
+
when TypeError
|
|
54
|
+
CoverageDataError.new("Invalid coverage data structure: #{error.message}", error)
|
|
55
|
+
when ArgumentError
|
|
56
|
+
convert_argument_error(error, context)
|
|
57
|
+
when NoMethodError
|
|
58
|
+
convert_no_method_error(error, context)
|
|
59
|
+
when RuntimeError
|
|
60
|
+
convert_runtime_error(error, context)
|
|
61
|
+
else
|
|
62
|
+
Error.new("An unexpected error occurred: #{error.message}", error)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private def convert_enoent(error, context)
|
|
67
|
+
if context == :coverage_loading
|
|
68
|
+
ResultsetNotFoundError.new('Coverage data not found', error)
|
|
69
|
+
else
|
|
70
|
+
filename = extract_filename(error.message)
|
|
71
|
+
FileNotFoundError.new("File not found: #{filename}", error)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private def convert_eacces(error, context)
|
|
76
|
+
if context == :coverage_loading
|
|
77
|
+
FilePermissionError.new("Permission denied reading coverage data: #{error.message}", error)
|
|
78
|
+
else
|
|
79
|
+
filename = extract_filename(error.message)
|
|
80
|
+
FilePermissionError.new("Permission denied accessing file: #{filename}", error)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private def convert_argument_error(error, context)
|
|
85
|
+
if context == :coverage_loading
|
|
86
|
+
CoverageDataError.new("Invalid path in coverage data: #{error.message}", error)
|
|
87
|
+
elsif error.message.include?('wrong number of arguments')
|
|
88
|
+
UsageError.new("Invalid number of arguments: #{error.message}", error)
|
|
89
|
+
else
|
|
90
|
+
ConfigurationError.new("Invalid configuration: #{error.message}", error)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private def convert_no_method_error(error, context)
|
|
95
|
+
if context == :coverage_loading
|
|
96
|
+
CoverageDataError.new("Invalid coverage data structure: #{error.message}", error)
|
|
97
|
+
else
|
|
98
|
+
method_info = extract_method_info(error.message)
|
|
99
|
+
CoverageDataError.new("Invalid coverage data structure - #{method_info}", error)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private def convert_runtime_error(error, context)
|
|
104
|
+
message = error.message
|
|
105
|
+
if message.include?('Could not find .resultset.json')
|
|
106
|
+
dir_info = message.match(/under (.+?)(?:;|$)/)&.[](1) || 'project directory'
|
|
107
|
+
CoverageDataError.new(
|
|
108
|
+
"Coverage data not found in #{dir_info} - please run your tests first", error)
|
|
109
|
+
elsif message.include?('No .resultset.json found in directory')
|
|
110
|
+
dir_info = message.match(/directory: (.+)$/)&.[](1) || 'specified directory'
|
|
111
|
+
CoverageDataError.new("Coverage data not found in directory: #{dir_info}", error)
|
|
112
|
+
elsif message.include?('Specified resultset not found')
|
|
113
|
+
# Preserve the original message format for consistency with existing tests
|
|
114
|
+
ResultsetNotFoundError.new(message, error)
|
|
115
|
+
elsif context == :coverage_loading
|
|
116
|
+
if message.downcase.include?('resultset')
|
|
117
|
+
ResultsetNotFoundError.new(message, error)
|
|
118
|
+
else
|
|
119
|
+
CoverageDataError.new("Failed to load coverage data: #{message}", error)
|
|
120
|
+
end
|
|
121
|
+
else
|
|
122
|
+
Error.new("An unexpected error occurred: #{message}", error)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private def log_error(error, context)
|
|
127
|
+
return unless log_errors?
|
|
128
|
+
|
|
129
|
+
message = build_log_message(error, context)
|
|
130
|
+
if logger
|
|
131
|
+
logger.error(message)
|
|
132
|
+
else
|
|
133
|
+
CovUtil.log(message)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private def build_log_message(error, context)
|
|
138
|
+
context_suffix = context ? " in #{context}" : ''
|
|
139
|
+
parts = ["Error#{context_suffix}: #{error.class}: #{error.message}"]
|
|
140
|
+
|
|
141
|
+
if show_stack_traces? && error.backtrace
|
|
142
|
+
parts << error.backtrace.join("\n")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
parts.join("\n")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private def extract_filename(message)
|
|
149
|
+
# Extract filename from "No such file or directory @ rb_sysopen - filename"
|
|
150
|
+
match = message.match(/@ \w+ - (.+)$/)
|
|
151
|
+
match ? match[1] : 'unknown file'
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private def extract_method_info(message)
|
|
155
|
+
match = message.match(/undefined method `(.+?)' for (.+)$/)
|
|
156
|
+
if match
|
|
157
|
+
method_name = match[1]
|
|
158
|
+
object_info = match[2].gsub(/#<.*?>/, 'object')
|
|
159
|
+
"missing method '#{method_name}' on #{object_info}"
|
|
160
|
+
else
|
|
161
|
+
message
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'error_handler'
|
|
4
|
+
|
|
5
|
+
module CovLoupe
|
|
6
|
+
module ErrorHandlerFactory
|
|
7
|
+
# Error handler for CLI usage
|
|
8
|
+
# - Logs errors for debugging
|
|
9
|
+
# - Shows stack traces only when explicitly requested
|
|
10
|
+
# - Suitable for user-facing command line interface
|
|
11
|
+
def self.for_cli(error_mode: :log)
|
|
12
|
+
ErrorHandler.new(error_mode: error_mode)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Error handler for library usage
|
|
16
|
+
# - No logging by default (avoids side effects in consuming applications)
|
|
17
|
+
# - No stack traces (libraries should let consumers handle error display)
|
|
18
|
+
# - Suitable for embedding in other applications
|
|
19
|
+
def self.for_library(error_mode: :off)
|
|
20
|
+
ErrorHandler.new(error_mode: error_mode)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Error handler for MCP server usage
|
|
24
|
+
# - Logs errors for server debugging
|
|
25
|
+
# - Shows stack traces only when explicitly requested
|
|
26
|
+
# - Suitable for long-running server processes
|
|
27
|
+
def self.for_mcp_server(error_mode: :log)
|
|
28
|
+
ErrorHandler.new(error_mode: error_mode)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CovLoupe
|
|
4
|
+
# Base error class for all SimpleCov MCP errors
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :original_error
|
|
7
|
+
|
|
8
|
+
def initialize(message = nil, original_error = nil)
|
|
9
|
+
@original_error = original_error
|
|
10
|
+
super(message)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def user_friendly_message
|
|
14
|
+
message
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
protected def format_epoch_both(epoch_seconds)
|
|
18
|
+
return [nil, nil] unless epoch_seconds
|
|
19
|
+
|
|
20
|
+
t = Time.at(epoch_seconds.to_i)
|
|
21
|
+
[t.utc.iso8601, t.getlocal.iso8601]
|
|
22
|
+
rescue
|
|
23
|
+
[epoch_seconds.to_s, epoch_seconds.to_s]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
protected def format_time_both(time)
|
|
27
|
+
return [nil, nil] unless time
|
|
28
|
+
|
|
29
|
+
t = time.is_a?(Time) ? time : Time.parse(time.to_s)
|
|
30
|
+
[t.utc.iso8601, t.getlocal.iso8601]
|
|
31
|
+
rescue
|
|
32
|
+
[time.to_s, time.to_s]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
protected def format_delta_seconds(file_mtime, cov_timestamp)
|
|
36
|
+
return nil unless file_mtime && cov_timestamp
|
|
37
|
+
|
|
38
|
+
seconds = file_mtime.to_i - cov_timestamp.to_i
|
|
39
|
+
sign = seconds >= 0 ? '+' : '-'
|
|
40
|
+
"#{sign}#{seconds.abs}s"
|
|
41
|
+
rescue
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Configuration or setup related errors
|
|
47
|
+
class ConfigurationError < Error
|
|
48
|
+
def user_friendly_message
|
|
49
|
+
"Configuration error: #{message}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# File or path related errors
|
|
54
|
+
class FileError < Error
|
|
55
|
+
def user_friendly_message
|
|
56
|
+
"File error: #{message}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# More specific file errors
|
|
61
|
+
class FileNotFoundError < FileError; end
|
|
62
|
+
class FilePermissionError < FileError; end
|
|
63
|
+
class NotAFileError < FileError; end
|
|
64
|
+
|
|
65
|
+
class ResultsetNotFoundError < FileError
|
|
66
|
+
def user_friendly_message
|
|
67
|
+
base = "File error: #{message}"
|
|
68
|
+
|
|
69
|
+
# Only add helpful tips in CLI and library modes, not MCP mode
|
|
70
|
+
unless CovLoupe.context.mcp_mode?
|
|
71
|
+
base += <<~HELP
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
Try one of the following:
|
|
75
|
+
- cd to a directory containing coverage/.resultset.json
|
|
76
|
+
- Specify a resultset: cov-loupe -r PATH
|
|
77
|
+
- Use -h for help: cov-loupe -h
|
|
78
|
+
HELP
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
base
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Coverage data related errors
|
|
86
|
+
class CoverageDataError < Error
|
|
87
|
+
def user_friendly_message
|
|
88
|
+
"Coverage data error: #{message}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Coverage data is present but appears stale compared to source files
|
|
93
|
+
class CoverageDataStaleError < CoverageDataError
|
|
94
|
+
attr_reader :file_path, :file_mtime, :cov_timestamp, :src_len, :cov_len, :resultset_path
|
|
95
|
+
|
|
96
|
+
def initialize(message = nil, original_error = nil, file_path: nil, file_mtime: nil,
|
|
97
|
+
cov_timestamp: nil, src_len: nil, cov_len: nil, resultset_path: nil)
|
|
98
|
+
@file_path = file_path
|
|
99
|
+
@file_mtime = file_mtime
|
|
100
|
+
@cov_timestamp = cov_timestamp
|
|
101
|
+
@src_len = src_len
|
|
102
|
+
@cov_len = cov_len
|
|
103
|
+
@resultset_path = resultset_path
|
|
104
|
+
super(message || default_message, original_error)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def user_friendly_message
|
|
108
|
+
"Coverage data stale: #{message}" + build_details
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private def default_message
|
|
112
|
+
fp = file_path || 'file'
|
|
113
|
+
"Coverage data appears stale for #{fp}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private def build_details
|
|
117
|
+
file_utc, file_local = format_time_both(@file_mtime)
|
|
118
|
+
cov_utc, cov_local = format_epoch_both(@cov_timestamp)
|
|
119
|
+
delta_str = format_delta_seconds(@file_mtime, @cov_timestamp)
|
|
120
|
+
|
|
121
|
+
details = <<~DETAILS
|
|
122
|
+
|
|
123
|
+
File - time: #{file_utc || 'not found'} (local #{file_local || 'n/a'}), lines: #{@src_len}
|
|
124
|
+
Coverage - time: #{cov_utc || 'not found'} (local #{cov_local || 'n/a'}), lines: #{@cov_len}
|
|
125
|
+
DETAILS
|
|
126
|
+
|
|
127
|
+
details += "\nDelta - file is #{delta_str} newer than coverage" if delta_str
|
|
128
|
+
details += "\nResultset - #{@resultset_path}" if @resultset_path
|
|
129
|
+
details.chomp
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Project-level stale coverage (global) — coverage timestamp older than
|
|
134
|
+
# one or more source files, or new tracked files missing from coverage.
|
|
135
|
+
class CoverageDataProjectStaleError < CoverageDataError
|
|
136
|
+
attr_reader :cov_timestamp, :newer_files, :missing_files, :deleted_files, :resultset_path
|
|
137
|
+
|
|
138
|
+
def initialize(message = nil, original_error = nil, cov_timestamp: nil, newer_files: [],
|
|
139
|
+
missing_files: [], deleted_files: [], resultset_path: nil)
|
|
140
|
+
super(message, original_error)
|
|
141
|
+
@cov_timestamp = cov_timestamp
|
|
142
|
+
@newer_files = Array(newer_files)
|
|
143
|
+
@missing_files = Array(missing_files)
|
|
144
|
+
@deleted_files = Array(deleted_files)
|
|
145
|
+
@resultset_path = resultset_path
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def user_friendly_message
|
|
149
|
+
base = "Coverage data stale (project): #{message || default_message}"
|
|
150
|
+
base + build_details
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private def default_message
|
|
154
|
+
'Coverage data appears stale for project'
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private def build_details
|
|
158
|
+
cov_utc, cov_local = format_epoch_both(@cov_timestamp)
|
|
159
|
+
parts = []
|
|
160
|
+
parts << "\nCoverage - time: #{cov_utc || 'not found'} (local #{cov_local || 'n/a'})"
|
|
161
|
+
unless @newer_files.empty?
|
|
162
|
+
parts << "\nNewer files (#{@newer_files.size}):"
|
|
163
|
+
parts.concat(@newer_files.first(10).map { |f| " - #{f}" })
|
|
164
|
+
parts << ' ...' if @newer_files.size > 10
|
|
165
|
+
end
|
|
166
|
+
unless @missing_files.empty?
|
|
167
|
+
parts << "\nMissing files (new in project, not in coverage, #{@missing_files.size}):"
|
|
168
|
+
parts.concat(@missing_files.first(10).map { |f| " - #{f}" })
|
|
169
|
+
parts << ' ...' if @missing_files.size > 10
|
|
170
|
+
end
|
|
171
|
+
unless @deleted_files.empty?
|
|
172
|
+
parts << "\nCoverage-only files (deleted or moved in project, #{@deleted_files.size}):"
|
|
173
|
+
parts.concat(@deleted_files.first(10).map { |f| " - #{f}" })
|
|
174
|
+
parts << ' ...' if @deleted_files.size > 10
|
|
175
|
+
end
|
|
176
|
+
parts << "\nResultset - #{@resultset_path}" if @resultset_path
|
|
177
|
+
parts.join
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Command line usage errors
|
|
182
|
+
class UsageError < Error
|
|
183
|
+
def self.for_subcommand(usage_fragment)
|
|
184
|
+
new("Usage: cov-loupe #{usage_fragment}")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def user_friendly_message
|
|
188
|
+
message
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CovLoupe
|
|
4
|
+
module Formatters
|
|
5
|
+
class SourceFormatter
|
|
6
|
+
def initialize(color_enabled: true)
|
|
7
|
+
@color_enabled = color_enabled
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def format_source_for(model, path, mode: nil, context: 2)
|
|
11
|
+
raw = fetch_raw(model, path)
|
|
12
|
+
return '[source not available]' unless raw
|
|
13
|
+
|
|
14
|
+
abs = raw['file']
|
|
15
|
+
lines_cov = raw['lines']
|
|
16
|
+
src = File.file?(abs) ? File.readlines(abs, chomp: true) : nil
|
|
17
|
+
return '[source not available]' unless src
|
|
18
|
+
|
|
19
|
+
begin
|
|
20
|
+
rows = build_source_rows(src, lines_cov, mode: mode, context: context)
|
|
21
|
+
format_source_rows(rows)
|
|
22
|
+
rescue ArgumentError
|
|
23
|
+
raise
|
|
24
|
+
rescue
|
|
25
|
+
# If any unexpected formatting/indexing error occurs, avoid crashing the CLI
|
|
26
|
+
'[source not available]'
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def build_source_payload(model, path, mode: nil, context: 2)
|
|
31
|
+
raw = fetch_raw(model, path)
|
|
32
|
+
return nil unless raw
|
|
33
|
+
|
|
34
|
+
abs = raw['file']
|
|
35
|
+
lines_cov = raw['lines']
|
|
36
|
+
src = File.file?(abs) ? File.readlines(abs, chomp: true) : nil
|
|
37
|
+
return nil unless src
|
|
38
|
+
|
|
39
|
+
build_source_rows(src, lines_cov, mode: mode, context: context)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def build_source_rows(src_lines, cov_lines, mode:, context: 2)
|
|
43
|
+
# Normalize inputs defensively to avoid type errors in formatting
|
|
44
|
+
coverage_lines = cov_lines || []
|
|
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?
|
|
51
|
+
|
|
52
|
+
n = src_lines.length
|
|
53
|
+
include_line = Array.new(n, mode == :full)
|
|
54
|
+
if mode == :uncovered
|
|
55
|
+
include_line = mark_uncovered_lines_with_context(coverage_lines, context_line_count, n)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
build_row_data(src_lines, coverage_lines, include_line)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def format_source_rows(rows)
|
|
62
|
+
marker = ->(covered, _hits) do
|
|
63
|
+
case covered
|
|
64
|
+
when true then colorize('✓', :green)
|
|
65
|
+
when false then colorize('·', :red)
|
|
66
|
+
else colorize(' ', :dim)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
lines = []
|
|
71
|
+
lines << format('%6s %2s | %s', 'Line', ' ', 'Source')
|
|
72
|
+
lines << format('%6s %2s-+-%s', '------', '--', '-' * 60)
|
|
73
|
+
|
|
74
|
+
rows.each do |r|
|
|
75
|
+
m = marker.call(r['covered'], r['hits'])
|
|
76
|
+
lines << format('%6d %2s | %s', r['line'], m, r['code'])
|
|
77
|
+
end
|
|
78
|
+
lines.join("\n")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def format_detailed_rows(rows)
|
|
82
|
+
# Simple aligned columns: line, hits, covered
|
|
83
|
+
out = []
|
|
84
|
+
out << format('%6s %6s %7s', 'Line', 'Hits', 'Covered')
|
|
85
|
+
out << format('%6s %6s %7s', '-----', '----', '-------')
|
|
86
|
+
rows.each do |r|
|
|
87
|
+
out << format('%6d %6d %5s', r['line'], r['hits'], r['covered'] ? 'yes' : 'no')
|
|
88
|
+
end
|
|
89
|
+
out.join("\n")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
attr_reader :color_enabled
|
|
93
|
+
|
|
94
|
+
private def fetch_raw(model, path)
|
|
95
|
+
@raw_cache ||= {}
|
|
96
|
+
return @raw_cache[path] if @raw_cache.key?(path)
|
|
97
|
+
|
|
98
|
+
raw = model.raw_for(path)
|
|
99
|
+
@raw_cache[path] = raw
|
|
100
|
+
rescue
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private def mark_uncovered_lines_with_context(coverage_lines, context_line_count, total_lines)
|
|
105
|
+
include_line = Array.new(total_lines, false)
|
|
106
|
+
misses = find_uncovered_lines(coverage_lines)
|
|
107
|
+
|
|
108
|
+
misses.each do |uncovered_line_index|
|
|
109
|
+
mark_context_lines(include_line, uncovered_line_index, context_line_count, total_lines)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
include_line
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private def find_uncovered_lines(coverage_lines)
|
|
116
|
+
misses = []
|
|
117
|
+
coverage_lines.each_with_index do |hits, i|
|
|
118
|
+
misses << i if !hits.nil? && hits.to_i == 0
|
|
119
|
+
end
|
|
120
|
+
misses
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private def mark_context_lines(include_line, center_line, context_count, total_lines)
|
|
124
|
+
start_line = [0, center_line - context_count].max
|
|
125
|
+
end_line = [total_lines - 1, center_line + context_count].min
|
|
126
|
+
|
|
127
|
+
(start_line..end_line).each { |i| include_line[i] = true }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private def build_row_data(src_lines, coverage_lines, include_line)
|
|
131
|
+
out = []
|
|
132
|
+
src_lines.each_with_index do |code, i|
|
|
133
|
+
next unless include_line[i]
|
|
134
|
+
|
|
135
|
+
hits = coverage_lines[i]
|
|
136
|
+
covered = hits.nil? ? nil : hits.to_i > 0
|
|
137
|
+
# Use string keys consistently across CLI formatting and JSON payloads
|
|
138
|
+
out << { 'line' => i + 1, 'code' => code, 'hits' => hits, 'covered' => covered }
|
|
139
|
+
end
|
|
140
|
+
out
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private def colorize(text, color)
|
|
144
|
+
return text unless color_enabled
|
|
145
|
+
|
|
146
|
+
codes = { green: 32, red: 31, dim: 2 }
|
|
147
|
+
code = codes[color] || 0
|
|
148
|
+
"\e[#{code}m#{text}\e[0m"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module CovLoupe
|
|
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
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CovLoupe
|
|
4
|
+
class MCPServer
|
|
5
|
+
def initialize(context: CovLoupe.context)
|
|
6
|
+
@context = context
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def run
|
|
10
|
+
CovLoupe.with_context(context) do
|
|
11
|
+
server = ::MCP::Server.new(
|
|
12
|
+
name: 'cov-loupe',
|
|
13
|
+
version: CovLoupe::VERSION,
|
|
14
|
+
tools: toolset
|
|
15
|
+
)
|
|
16
|
+
::MCP::Server::Transports::StdioTransport.new(server).open
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
TOOLSET = [
|
|
21
|
+
Tools::AllFilesCoverageTool,
|
|
22
|
+
Tools::CoverageDetailedTool,
|
|
23
|
+
Tools::CoverageRawTool,
|
|
24
|
+
Tools::CoverageSummaryTool,
|
|
25
|
+
Tools::CoverageTotalsTool,
|
|
26
|
+
Tools::UncoveredLinesTool,
|
|
27
|
+
Tools::CoverageTableTool,
|
|
28
|
+
Tools::ValidateTool,
|
|
29
|
+
Tools::HelpTool,
|
|
30
|
+
Tools::VersionTool
|
|
31
|
+
].freeze
|
|
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
|
+
|
|
40
|
+
attr_reader :context
|
|
41
|
+
end
|
|
42
|
+
end
|