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.
Files changed (171) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +329 -0
  4. data/docs/dev/ARCHITECTURE.md +80 -0
  5. data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
  6. data/docs/dev/DEVELOPMENT.md +83 -0
  7. data/docs/dev/README.md +10 -0
  8. data/docs/dev/RELEASING.md +146 -0
  9. data/docs/dev/arch-decisions/001-x-arch-decision.md +95 -0
  10. data/docs/dev/arch-decisions/002-x-arch-decision.md +159 -0
  11. data/docs/dev/arch-decisions/003-x-arch-decision.md +165 -0
  12. data/docs/dev/arch-decisions/004-x-arch-decision.md +203 -0
  13. data/docs/dev/arch-decisions/005-x-arch-decision.md +189 -0
  14. data/docs/dev/arch-decisions/README.md +60 -0
  15. data/docs/dev/presentations/cov-loupe-presentation.md +255 -0
  16. data/docs/fixtures/demo_project/README.md +9 -0
  17. data/docs/user/ADVANCED_USAGE.md +777 -0
  18. data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
  19. data/docs/user/CLI_USAGE.md +750 -0
  20. data/docs/user/ERROR_HANDLING.md +93 -0
  21. data/docs/user/EXAMPLES.md +588 -0
  22. data/docs/user/INSTALLATION.md +130 -0
  23. data/docs/user/LIBRARY_API.md +693 -0
  24. data/docs/user/MCP_INTEGRATION.md +490 -0
  25. data/docs/user/README.md +14 -0
  26. data/docs/user/TROUBLESHOOTING.md +197 -0
  27. data/docs/user/V2-BREAKING-CHANGES.md +472 -0
  28. data/exe/cov-loupe +23 -0
  29. data/lib/cov_loupe/app_config.rb +56 -0
  30. data/lib/cov_loupe/app_context.rb +26 -0
  31. data/lib/cov_loupe/base_tool.rb +102 -0
  32. data/lib/cov_loupe/cli.rb +178 -0
  33. data/lib/cov_loupe/commands/base_command.rb +67 -0
  34. data/lib/cov_loupe/commands/command_factory.rb +45 -0
  35. data/lib/cov_loupe/commands/detailed_command.rb +38 -0
  36. data/lib/cov_loupe/commands/list_command.rb +13 -0
  37. data/lib/cov_loupe/commands/raw_command.rb +38 -0
  38. data/lib/cov_loupe/commands/summary_command.rb +41 -0
  39. data/lib/cov_loupe/commands/totals_command.rb +53 -0
  40. data/lib/cov_loupe/commands/uncovered_command.rb +45 -0
  41. data/lib/cov_loupe/commands/validate_command.rb +60 -0
  42. data/lib/cov_loupe/commands/version_command.rb +33 -0
  43. data/lib/cov_loupe/config_parser.rb +32 -0
  44. data/lib/cov_loupe/constants.rb +22 -0
  45. data/lib/cov_loupe/coverage_reporter.rb +31 -0
  46. data/lib/cov_loupe/error_handler.rb +165 -0
  47. data/lib/cov_loupe/error_handler_factory.rb +31 -0
  48. data/lib/cov_loupe/errors.rb +191 -0
  49. data/lib/cov_loupe/formatters/source_formatter.rb +152 -0
  50. data/lib/cov_loupe/formatters.rb +51 -0
  51. data/lib/cov_loupe/mcp_server.rb +42 -0
  52. data/lib/cov_loupe/mode_detector.rb +56 -0
  53. data/lib/cov_loupe/model.rb +339 -0
  54. data/lib/cov_loupe/option_normalizers.rb +113 -0
  55. data/lib/cov_loupe/option_parser_builder.rb +147 -0
  56. data/lib/cov_loupe/option_parsers/env_options_parser.rb +48 -0
  57. data/lib/cov_loupe/option_parsers/error_helper.rb +110 -0
  58. data/lib/cov_loupe/path_relativizer.rb +64 -0
  59. data/lib/cov_loupe/predicate_evaluator.rb +72 -0
  60. data/lib/cov_loupe/presenters/base_coverage_presenter.rb +42 -0
  61. data/lib/cov_loupe/presenters/coverage_detailed_presenter.rb +14 -0
  62. data/lib/cov_loupe/presenters/coverage_raw_presenter.rb +14 -0
  63. data/lib/cov_loupe/presenters/coverage_summary_presenter.rb +14 -0
  64. data/lib/cov_loupe/presenters/coverage_uncovered_presenter.rb +14 -0
  65. data/lib/cov_loupe/presenters/project_coverage_presenter.rb +50 -0
  66. data/lib/cov_loupe/presenters/project_totals_presenter.rb +27 -0
  67. data/lib/cov_loupe/resolvers/coverage_line_resolver.rb +122 -0
  68. data/lib/cov_loupe/resolvers/resolver_factory.rb +28 -0
  69. data/lib/cov_loupe/resolvers/resultset_path_resolver.rb +76 -0
  70. data/lib/cov_loupe/resultset_loader.rb +131 -0
  71. data/lib/cov_loupe/staleness_checker.rb +247 -0
  72. data/lib/cov_loupe/table_formatter.rb +64 -0
  73. data/lib/cov_loupe/tools/all_files_coverage_tool.rb +51 -0
  74. data/lib/cov_loupe/tools/coverage_detailed_tool.rb +35 -0
  75. data/lib/cov_loupe/tools/coverage_raw_tool.rb +34 -0
  76. data/lib/cov_loupe/tools/coverage_summary_tool.rb +34 -0
  77. data/lib/cov_loupe/tools/coverage_table_tool.rb +50 -0
  78. data/lib/cov_loupe/tools/coverage_totals_tool.rb +44 -0
  79. data/lib/cov_loupe/tools/help_tool.rb +115 -0
  80. data/lib/cov_loupe/tools/uncovered_lines_tool.rb +34 -0
  81. data/lib/cov_loupe/tools/validate_tool.rb +72 -0
  82. data/lib/cov_loupe/tools/version_tool.rb +32 -0
  83. data/lib/cov_loupe/util.rb +88 -0
  84. data/lib/cov_loupe/version.rb +5 -0
  85. data/lib/cov_loupe.rb +140 -0
  86. data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
  87. data/spec/TIMESTAMPS.md +48 -0
  88. data/spec/all_files_coverage_tool_spec.rb +53 -0
  89. data/spec/app_config_spec.rb +142 -0
  90. data/spec/base_tool_spec.rb +62 -0
  91. data/spec/cli/show_default_report_spec.rb +33 -0
  92. data/spec/cli_enumerated_options_spec.rb +90 -0
  93. data/spec/cli_error_spec.rb +184 -0
  94. data/spec/cli_format_spec.rb +123 -0
  95. data/spec/cli_json_options_spec.rb +50 -0
  96. data/spec/cli_source_spec.rb +44 -0
  97. data/spec/cli_spec.rb +192 -0
  98. data/spec/cli_table_spec.rb +28 -0
  99. data/spec/cli_usage_spec.rb +42 -0
  100. data/spec/commands/base_command_spec.rb +107 -0
  101. data/spec/commands/command_factory_spec.rb +76 -0
  102. data/spec/commands/detailed_command_spec.rb +34 -0
  103. data/spec/commands/list_command_spec.rb +28 -0
  104. data/spec/commands/raw_command_spec.rb +69 -0
  105. data/spec/commands/summary_command_spec.rb +34 -0
  106. data/spec/commands/totals_command_spec.rb +34 -0
  107. data/spec/commands/uncovered_command_spec.rb +55 -0
  108. data/spec/commands/validate_command_spec.rb +213 -0
  109. data/spec/commands/version_command_spec.rb +38 -0
  110. data/spec/constants_spec.rb +61 -0
  111. data/spec/cov_loupe/formatters/source_formatter_spec.rb +267 -0
  112. data/spec/cov_loupe/formatters_spec.rb +76 -0
  113. data/spec/cov_loupe/presenters/base_coverage_presenter_spec.rb +79 -0
  114. data/spec/cov_loupe_model_spec.rb +454 -0
  115. data/spec/cov_loupe_module_spec.rb +37 -0
  116. data/spec/cov_loupe_opts_spec.rb +185 -0
  117. data/spec/coverage_reporter_spec.rb +102 -0
  118. data/spec/coverage_table_tool_spec.rb +59 -0
  119. data/spec/coverage_totals_tool_spec.rb +37 -0
  120. data/spec/error_handler_spec.rb +197 -0
  121. data/spec/error_mode_spec.rb +139 -0
  122. data/spec/errors_edge_cases_spec.rb +312 -0
  123. data/spec/errors_stale_spec.rb +83 -0
  124. data/spec/file_based_mcp_tools_spec.rb +99 -0
  125. data/spec/fixtures/project1/lib/bar.rb +5 -0
  126. data/spec/fixtures/project1/lib/foo.rb +6 -0
  127. data/spec/help_tool_spec.rb +26 -0
  128. data/spec/integration_spec.rb +789 -0
  129. data/spec/logging_fallback_spec.rb +128 -0
  130. data/spec/mcp_logging_spec.rb +44 -0
  131. data/spec/mcp_server_integration_spec.rb +23 -0
  132. data/spec/mcp_server_spec.rb +106 -0
  133. data/spec/mode_detector_spec.rb +153 -0
  134. data/spec/model_error_handling_spec.rb +269 -0
  135. data/spec/model_staleness_spec.rb +79 -0
  136. data/spec/option_normalizers_spec.rb +203 -0
  137. data/spec/option_parsers/env_options_parser_spec.rb +221 -0
  138. data/spec/option_parsers/error_helper_spec.rb +222 -0
  139. data/spec/path_relativizer_spec.rb +98 -0
  140. data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
  141. data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
  142. data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
  143. data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
  144. data/spec/presenters/project_coverage_presenter_spec.rb +87 -0
  145. data/spec/presenters/project_totals_presenter_spec.rb +144 -0
  146. data/spec/resolvers/coverage_line_resolver_spec.rb +282 -0
  147. data/spec/resolvers/resolver_factory_spec.rb +61 -0
  148. data/spec/resolvers/resultset_path_resolver_spec.rb +60 -0
  149. data/spec/resultset_loader_spec.rb +167 -0
  150. data/spec/shared_examples/README.md +115 -0
  151. data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
  152. data/spec/shared_examples/file_based_mcp_tools.rb +179 -0
  153. data/spec/shared_examples/formatted_command_examples.rb +64 -0
  154. data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
  155. data/spec/spec_helper.rb +127 -0
  156. data/spec/staleness_checker_spec.rb +374 -0
  157. data/spec/staleness_more_spec.rb +42 -0
  158. data/spec/support/cli_helpers.rb +22 -0
  159. data/spec/support/control_flow_helpers.rb +20 -0
  160. data/spec/support/fake_mcp.rb +40 -0
  161. data/spec/support/io_helpers.rb +29 -0
  162. data/spec/support/mcp_helpers.rb +35 -0
  163. data/spec/support/mcp_runner.rb +66 -0
  164. data/spec/support/mocking_helpers.rb +30 -0
  165. data/spec/table_format_spec.rb +70 -0
  166. data/spec/tools/validate_tool_spec.rb +132 -0
  167. data/spec/tools_error_handling_spec.rb +130 -0
  168. data/spec/util_spec.rb +154 -0
  169. data/spec/version_spec.rb +123 -0
  170. data/spec/version_tool_spec.rb +141 -0
  171. 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