cov-loupe 3.0.0 → 4.0.0.pre

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 (281) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +230 -0
  3. data/CLAUDE.md +5 -0
  4. data/CODE_OF_CONDUCT.md +62 -0
  5. data/CONTRIBUTING.md +102 -0
  6. data/GEMINI.md +5 -0
  7. data/README.md +154 -51
  8. data/RELEASE_NOTES.md +452 -0
  9. data/dev/images/cov-loupe-icon-lores.png +0 -0
  10. data/dev/images/cov-loupe-icon-square.png +0 -0
  11. data/dev/images/cov-loupe-icon.png +0 -0
  12. data/dev/images/cov-loupe-logo.png +0 -0
  13. data/dev/prompts/README.md +74 -0
  14. data/dev/prompts/archive/architectural-review-and-actions-prompt.md +53 -0
  15. data/dev/prompts/archive/investigate-and-report-issues-prompt.md +33 -0
  16. data/dev/prompts/archive/produce-action-items-prompt.md +25 -0
  17. data/dev/prompts/guidelines/ai-code-evaluator-guidelines.md +337 -0
  18. data/dev/prompts/improve/refactor-test-suite.md +18 -0
  19. data/dev/prompts/improve/simplify-code-logic.md +133 -0
  20. data/dev/prompts/improve/update-documentation.md +21 -0
  21. data/dev/prompts/review/comprehensive-codebase-review.md +176 -0
  22. data/dev/prompts/review/identify-action-items.md +143 -0
  23. data/dev/prompts/review/verify-code-changes.md +54 -0
  24. data/dev/prompts/validate/create-screencast-outline.md +234 -0
  25. data/dev/prompts/validate/test-documentation-examples.md +180 -0
  26. data/docs/QUICKSTART.md +63 -0
  27. data/docs/assets/images/cov-loupe-logo-lores.png +0 -0
  28. data/docs/assets/images/cov-loupe-logo.png +0 -0
  29. data/docs/assets/images/favicon.png +0 -0
  30. data/docs/assets/stylesheets/branding.css +16 -0
  31. data/docs/assets/stylesheets/extra.css +15 -0
  32. data/docs/code_of_conduct.md +1 -0
  33. data/docs/contributing.md +1 -0
  34. data/docs/dev/ARCHITECTURE.md +56 -11
  35. data/docs/dev/DEVELOPMENT.md +116 -12
  36. data/docs/dev/FUTURE_ENHANCEMENTS.md +14 -0
  37. data/docs/dev/README.md +3 -2
  38. data/docs/dev/RELEASING.md +2 -0
  39. data/docs/dev/arch-decisions/README.md +10 -7
  40. data/docs/dev/arch-decisions/application-architecture.md +259 -0
  41. data/docs/dev/arch-decisions/coverage-data-quality.md +193 -0
  42. data/docs/dev/arch-decisions/output-character-mode.md +217 -0
  43. data/docs/dev/arch-decisions/path-resolution.md +90 -0
  44. data/docs/dev/arch-decisions/{004-x-arch-decision.md → policy-validation.md} +32 -28
  45. data/docs/dev/arch-decisions/{005-x-arch-decision.md → simplecov-integration.md} +47 -44
  46. data/docs/dev/presentations/cov-loupe-presentation.md +15 -13
  47. data/docs/examples/mcp-inputs.md +3 -0
  48. data/docs/examples/prompts.md +3 -0
  49. data/docs/examples/success_predicates.md +3 -0
  50. data/docs/fixtures/demo_project/.resultset.json +170 -0
  51. data/docs/fixtures/demo_project/README.md +6 -0
  52. data/docs/fixtures/demo_project/app/controllers/admin/audit_logs_controller.rb +19 -0
  53. data/docs/fixtures/demo_project/app/controllers/orders_controller.rb +26 -0
  54. data/docs/fixtures/demo_project/app/models/order.rb +20 -0
  55. data/docs/fixtures/demo_project/app/models/user.rb +19 -0
  56. data/docs/fixtures/demo_project/lib/api/client.rb +22 -0
  57. data/docs/fixtures/demo_project/lib/ops/jobs/cleanup_job.rb +16 -0
  58. data/docs/fixtures/demo_project/lib/ops/jobs/report_job.rb +17 -0
  59. data/docs/fixtures/demo_project/lib/payments/processor.rb +15 -0
  60. data/docs/fixtures/demo_project/lib/payments/refund_service.rb +15 -0
  61. data/docs/fixtures/demo_project/lib/payments/reporting/exporter.rb +16 -0
  62. data/docs/index.md +1 -0
  63. data/docs/license.md +3 -0
  64. data/docs/release_notes.md +3 -0
  65. data/docs/user/ADVANCED_USAGE.md +208 -115
  66. data/docs/user/CLI_FALLBACK_FOR_LLMS.md +2 -0
  67. data/docs/user/CLI_USAGE.md +276 -101
  68. data/docs/user/ERROR_HANDLING.md +4 -4
  69. data/docs/user/EXAMPLES.md +121 -128
  70. data/docs/user/INSTALLATION.md +9 -28
  71. data/docs/user/LIBRARY_API.md +227 -122
  72. data/docs/user/MCP_INTEGRATION.md +114 -203
  73. data/docs/user/README.md +5 -1
  74. data/docs/user/TROUBLESHOOTING.md +49 -27
  75. data/docs/user/installing-a-prelease-version-of-covloupe.md +43 -0
  76. data/docs/user/{V2-BREAKING-CHANGES.md → migrations/MIGRATING_TO_V2.md} +62 -72
  77. data/docs/user/migrations/MIGRATING_TO_V3.md +72 -0
  78. data/docs/user/migrations/MIGRATING_TO_V4.md +591 -0
  79. data/docs/user/migrations/README.md +22 -0
  80. data/docs/user/prompts/README.md +9 -0
  81. data/docs/user/prompts/non-web-coverage-analysis-prompt.md +103 -0
  82. data/docs/user/prompts/rails-coverage-analysis-prompt.md +94 -0
  83. data/docs/user/prompts/use-cli-not-mcp-prompt.md +53 -0
  84. data/examples/cli_demo.sh +77 -0
  85. data/examples/filter_and_table_demo-output.md +114 -0
  86. data/examples/filter_and_table_demo.rb +174 -0
  87. data/examples/fixtures/demo_project/coverage/.resultset.json +10 -0
  88. data/examples/mcp-inputs/README.md +66 -0
  89. data/examples/mcp-inputs/coverage_detailed.json +1 -0
  90. data/examples/mcp-inputs/coverage_raw.json +1 -0
  91. data/examples/mcp-inputs/coverage_summary.json +1 -0
  92. data/examples/mcp-inputs/list.json +1 -0
  93. data/examples/mcp-inputs/uncovered_lines.json +1 -0
  94. data/examples/prompts/README.md +27 -0
  95. data/examples/prompts/custom_resultset.txt +2 -0
  96. data/examples/prompts/detailed_with_source.txt +2 -0
  97. data/examples/prompts/list_lowest.txt +2 -0
  98. data/examples/prompts/summary.txt +2 -0
  99. data/examples/prompts/uncovered.txt +2 -0
  100. data/examples/success_predicates/README.md +198 -0
  101. data/examples/success_predicates/all_files_above_threshold_predicate.rb +21 -0
  102. data/examples/success_predicates/directory_specific_thresholds_predicate.rb +30 -0
  103. data/examples/success_predicates/project_coverage_minimum_predicate.rb +6 -0
  104. data/lib/cov_loupe/base_tool.rb +229 -20
  105. data/lib/cov_loupe/cli.rb +132 -23
  106. data/lib/cov_loupe/commands/base_command.rb +25 -6
  107. data/lib/cov_loupe/commands/command_factory.rb +0 -1
  108. data/lib/cov_loupe/commands/detailed_command.rb +10 -5
  109. data/lib/cov_loupe/commands/list_command.rb +2 -1
  110. data/lib/cov_loupe/commands/raw_command.rb +7 -5
  111. data/lib/cov_loupe/commands/summary_command.rb +12 -7
  112. data/lib/cov_loupe/commands/totals_command.rb +74 -10
  113. data/lib/cov_loupe/commands/uncovered_command.rb +7 -5
  114. data/lib/cov_loupe/commands/validate_command.rb +11 -3
  115. data/lib/cov_loupe/commands/version_command.rb +6 -4
  116. data/lib/cov_loupe/{app_config.rb → config/app_config.rb} +13 -5
  117. data/lib/cov_loupe/config/app_context.rb +43 -0
  118. data/lib/cov_loupe/config/boolean_type.rb +91 -0
  119. data/lib/cov_loupe/config/logger.rb +92 -0
  120. data/lib/cov_loupe/{option_normalizers.rb → config/option_normalizers.rb} +55 -24
  121. data/lib/cov_loupe/{option_parser_builder.rb → config/option_parser_builder.rb} +46 -24
  122. data/lib/cov_loupe/coverage/coverage_calculator.rb +53 -0
  123. data/lib/cov_loupe/coverage/coverage_reporter.rb +63 -0
  124. data/lib/cov_loupe/coverage/coverage_table_formatter.rb +133 -0
  125. data/lib/cov_loupe/{error_handler.rb → errors/error_handler.rb} +21 -33
  126. data/lib/cov_loupe/{errors.rb → errors/errors.rb} +48 -71
  127. data/lib/cov_loupe/formatters/formatters.rb +75 -0
  128. data/lib/cov_loupe/formatters/source_formatter.rb +18 -7
  129. data/lib/cov_loupe/formatters/table_formatter.rb +80 -0
  130. data/lib/cov_loupe/loaders/all.rb +15 -0
  131. data/lib/cov_loupe/loaders/all_cli.rb +10 -0
  132. data/lib/cov_loupe/loaders/all_mcp.rb +23 -0
  133. data/lib/cov_loupe/loaders/resultset_loader.rb +147 -0
  134. data/lib/cov_loupe/mcp_server.rb +3 -2
  135. data/lib/cov_loupe/model/model.rb +520 -0
  136. data/lib/cov_loupe/model/model_data.rb +13 -0
  137. data/lib/cov_loupe/model/model_data_cache.rb +116 -0
  138. data/lib/cov_loupe/option_parsers/env_options_parser.rb +17 -6
  139. data/lib/cov_loupe/option_parsers/error_helper.rb +16 -10
  140. data/lib/cov_loupe/output_chars.rb +192 -0
  141. data/lib/cov_loupe/paths/glob_utils.rb +100 -0
  142. data/lib/cov_loupe/{path_relativizer.rb → paths/path_relativizer.rb} +5 -13
  143. data/lib/cov_loupe/paths/path_utils.rb +265 -0
  144. data/lib/cov_loupe/paths/volume_case_sensitivity.rb +173 -0
  145. data/lib/cov_loupe/presenters/base_coverage_presenter.rb +9 -13
  146. data/lib/cov_loupe/presenters/coverage_payload_presenter.rb +21 -0
  147. data/lib/cov_loupe/presenters/payload_caching.rb +23 -0
  148. data/lib/cov_loupe/presenters/project_coverage_presenter.rb +73 -21
  149. data/lib/cov_loupe/presenters/project_totals_presenter.rb +16 -10
  150. data/lib/cov_loupe/repositories/coverage_repository.rb +149 -0
  151. data/lib/cov_loupe/resolvers/coverage_line_resolver.rb +90 -76
  152. data/lib/cov_loupe/resolvers/{resolver_factory.rb → resolver_helpers.rb} +6 -5
  153. data/lib/cov_loupe/resolvers/resultset_path_resolver.rb +40 -12
  154. data/lib/cov_loupe/scripts/command_execution.rb +113 -0
  155. data/lib/cov_loupe/scripts/latest_ci_status.rb +97 -0
  156. data/lib/cov_loupe/scripts/pre_release_check.rb +164 -0
  157. data/lib/cov_loupe/scripts/setup_doc_server.rb +23 -0
  158. data/lib/cov_loupe/scripts/start_doc_server.rb +24 -0
  159. data/lib/cov_loupe/staleness/stale_status.rb +23 -0
  160. data/lib/cov_loupe/staleness/staleness_checker.rb +328 -0
  161. data/lib/cov_loupe/staleness/staleness_message_formatter.rb +91 -0
  162. data/lib/cov_loupe/tools/coverage_detailed_tool.rb +14 -15
  163. data/lib/cov_loupe/tools/coverage_raw_tool.rb +14 -14
  164. data/lib/cov_loupe/tools/coverage_summary_tool.rb +16 -16
  165. data/lib/cov_loupe/tools/coverage_table_tool.rb +139 -21
  166. data/lib/cov_loupe/tools/coverage_totals_tool.rb +31 -13
  167. data/lib/cov_loupe/tools/help_tool.rb +16 -20
  168. data/lib/cov_loupe/tools/list_tool.rb +65 -0
  169. data/lib/cov_loupe/tools/uncovered_lines_tool.rb +14 -14
  170. data/lib/cov_loupe/tools/validate_tool.rb +18 -24
  171. data/lib/cov_loupe/tools/version_tool.rb +8 -3
  172. data/lib/cov_loupe/version.rb +1 -1
  173. data/lib/cov_loupe.rb +83 -55
  174. metadata +184 -154
  175. data/docs/dev/BRANCH_ONLY_COVERAGE.md +0 -158
  176. data/docs/dev/arch-decisions/001-x-arch-decision.md +0 -95
  177. data/docs/dev/arch-decisions/002-x-arch-decision.md +0 -159
  178. data/docs/dev/arch-decisions/003-x-arch-decision.md +0 -165
  179. data/lib/cov_loupe/app_context.rb +0 -26
  180. data/lib/cov_loupe/constants.rb +0 -22
  181. data/lib/cov_loupe/coverage_reporter.rb +0 -31
  182. data/lib/cov_loupe/formatters.rb +0 -51
  183. data/lib/cov_loupe/mode_detector.rb +0 -56
  184. data/lib/cov_loupe/model.rb +0 -339
  185. data/lib/cov_loupe/presenters/coverage_detailed_presenter.rb +0 -14
  186. data/lib/cov_loupe/presenters/coverage_raw_presenter.rb +0 -14
  187. data/lib/cov_loupe/presenters/coverage_summary_presenter.rb +0 -14
  188. data/lib/cov_loupe/presenters/coverage_uncovered_presenter.rb +0 -14
  189. data/lib/cov_loupe/resultset_loader.rb +0 -131
  190. data/lib/cov_loupe/staleness_checker.rb +0 -247
  191. data/lib/cov_loupe/table_formatter.rb +0 -64
  192. data/lib/cov_loupe/tools/all_files_coverage_tool.rb +0 -51
  193. data/lib/cov_loupe/util.rb +0 -88
  194. data/spec/MCP_INTEGRATION_TESTS_README.md +0 -111
  195. data/spec/TIMESTAMPS.md +0 -48
  196. data/spec/all_files_coverage_tool_spec.rb +0 -53
  197. data/spec/app_config_spec.rb +0 -142
  198. data/spec/base_tool_spec.rb +0 -62
  199. data/spec/cli/show_default_report_spec.rb +0 -33
  200. data/spec/cli_enumerated_options_spec.rb +0 -90
  201. data/spec/cli_error_spec.rb +0 -184
  202. data/spec/cli_format_spec.rb +0 -123
  203. data/spec/cli_json_options_spec.rb +0 -50
  204. data/spec/cli_source_spec.rb +0 -44
  205. data/spec/cli_spec.rb +0 -192
  206. data/spec/cli_table_spec.rb +0 -28
  207. data/spec/cli_usage_spec.rb +0 -42
  208. data/spec/commands/base_command_spec.rb +0 -107
  209. data/spec/commands/command_factory_spec.rb +0 -76
  210. data/spec/commands/detailed_command_spec.rb +0 -34
  211. data/spec/commands/list_command_spec.rb +0 -28
  212. data/spec/commands/raw_command_spec.rb +0 -69
  213. data/spec/commands/summary_command_spec.rb +0 -34
  214. data/spec/commands/totals_command_spec.rb +0 -34
  215. data/spec/commands/uncovered_command_spec.rb +0 -55
  216. data/spec/commands/validate_command_spec.rb +0 -213
  217. data/spec/commands/version_command_spec.rb +0 -38
  218. data/spec/constants_spec.rb +0 -61
  219. data/spec/cov_loupe/formatters/source_formatter_spec.rb +0 -267
  220. data/spec/cov_loupe/formatters_spec.rb +0 -76
  221. data/spec/cov_loupe/presenters/base_coverage_presenter_spec.rb +0 -79
  222. data/spec/cov_loupe_model_spec.rb +0 -454
  223. data/spec/cov_loupe_module_spec.rb +0 -37
  224. data/spec/cov_loupe_opts_spec.rb +0 -185
  225. data/spec/coverage_reporter_spec.rb +0 -102
  226. data/spec/coverage_table_tool_spec.rb +0 -59
  227. data/spec/coverage_totals_tool_spec.rb +0 -37
  228. data/spec/error_handler_spec.rb +0 -197
  229. data/spec/error_mode_spec.rb +0 -139
  230. data/spec/errors_edge_cases_spec.rb +0 -312
  231. data/spec/errors_stale_spec.rb +0 -83
  232. data/spec/file_based_mcp_tools_spec.rb +0 -99
  233. data/spec/help_tool_spec.rb +0 -26
  234. data/spec/integration_spec.rb +0 -789
  235. data/spec/logging_fallback_spec.rb +0 -128
  236. data/spec/mcp_logging_spec.rb +0 -44
  237. data/spec/mcp_server_integration_spec.rb +0 -23
  238. data/spec/mcp_server_spec.rb +0 -106
  239. data/spec/mode_detector_spec.rb +0 -153
  240. data/spec/model_error_handling_spec.rb +0 -269
  241. data/spec/model_staleness_spec.rb +0 -79
  242. data/spec/option_normalizers_spec.rb +0 -203
  243. data/spec/option_parsers/env_options_parser_spec.rb +0 -221
  244. data/spec/option_parsers/error_helper_spec.rb +0 -222
  245. data/spec/path_relativizer_spec.rb +0 -98
  246. data/spec/presenters/coverage_detailed_presenter_spec.rb +0 -19
  247. data/spec/presenters/coverage_raw_presenter_spec.rb +0 -15
  248. data/spec/presenters/coverage_summary_presenter_spec.rb +0 -15
  249. data/spec/presenters/coverage_uncovered_presenter_spec.rb +0 -16
  250. data/spec/presenters/project_coverage_presenter_spec.rb +0 -87
  251. data/spec/presenters/project_totals_presenter_spec.rb +0 -144
  252. data/spec/resolvers/coverage_line_resolver_spec.rb +0 -282
  253. data/spec/resolvers/resolver_factory_spec.rb +0 -61
  254. data/spec/resolvers/resultset_path_resolver_spec.rb +0 -60
  255. data/spec/resultset_loader_spec.rb +0 -167
  256. data/spec/shared_examples/README.md +0 -115
  257. data/spec/shared_examples/coverage_presenter_examples.rb +0 -66
  258. data/spec/shared_examples/file_based_mcp_tools.rb +0 -179
  259. data/spec/shared_examples/formatted_command_examples.rb +0 -64
  260. data/spec/shared_examples/mcp_tool_text_json_response.rb +0 -16
  261. data/spec/spec_helper.rb +0 -127
  262. data/spec/staleness_checker_spec.rb +0 -374
  263. data/spec/staleness_more_spec.rb +0 -42
  264. data/spec/support/cli_helpers.rb +0 -22
  265. data/spec/support/control_flow_helpers.rb +0 -20
  266. data/spec/support/fake_mcp.rb +0 -40
  267. data/spec/support/io_helpers.rb +0 -29
  268. data/spec/support/mcp_helpers.rb +0 -35
  269. data/spec/support/mcp_runner.rb +0 -66
  270. data/spec/support/mocking_helpers.rb +0 -30
  271. data/spec/table_format_spec.rb +0 -70
  272. data/spec/tools/validate_tool_spec.rb +0 -132
  273. data/spec/tools_error_handling_spec.rb +0 -130
  274. data/spec/util_spec.rb +0 -154
  275. data/spec/version_spec.rb +0 -123
  276. data/spec/version_tool_spec.rb +0 -141
  277. /data/{spec/fixtures/project1 → examples/fixtures/demo_project}/lib/bar.rb +0 -0
  278. /data/{spec/fixtures/project1 → examples/fixtures/demo_project}/lib/foo.rb +0 -0
  279. /data/lib/cov_loupe/{config_parser.rb → config/config_parser.rb} +0 -0
  280. /data/lib/cov_loupe/{predicate_evaluator.rb → config/predicate_evaluator.rb} +0 -0
  281. /data/lib/cov_loupe/{error_handler_factory.rb → errors/error_handler_factory.rb} +0 -0
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../staleness/staleness_message_formatter'
4
+
3
5
  module CovLoupe
4
6
  # Base error class for all SimpleCov MCP errors
5
7
  class Error < StandardError
@@ -13,34 +15,6 @@ module CovLoupe
13
15
  def user_friendly_message
14
16
  message
15
17
  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
18
  end
45
19
 
46
20
  # Configuration or setup related errors
@@ -50,6 +24,13 @@ module CovLoupe
50
24
  end
51
25
  end
52
26
 
27
+ # Error wrapper when the root cause is unknown or unclassified.
28
+ class UnknownError < Error
29
+ def user_friendly_message
30
+ "An unexpected error occurred: #{message}"
31
+ end
32
+ end
33
+
53
34
  # File or path related errors
54
35
  class FileError < Error
55
36
  def user_friendly_message
@@ -89,8 +70,26 @@ module CovLoupe
89
70
  end
90
71
  end
91
72
 
73
+ class CorruptCoverageDataError < CoverageDataError
74
+ def user_friendly_message
75
+ "Corrupt coverage data: #{message}"
76
+ end
77
+ end
78
+
79
+ # Shared module for stale error formatting
80
+ module StalenessFormatterMixin
81
+ private def formatter
82
+ @formatter ||= StalenessMessageFormatter.new(
83
+ cov_timestamp: @cov_timestamp,
84
+ resultset_path: @resultset_path
85
+ )
86
+ end
87
+ end
88
+
92
89
  # Coverage data is present but appears stale compared to source files
93
90
  class CoverageDataStaleError < CoverageDataError
91
+ include StalenessFormatterMixin
92
+
94
93
  attr_reader :file_path, :file_mtime, :cov_timestamp, :src_len, :cov_len, :resultset_path
95
94
 
96
95
  def initialize(message = nil, original_error = nil, file_path: nil, file_mtime: nil,
@@ -105,77 +104,55 @@ module CovLoupe
105
104
  end
106
105
 
107
106
  def user_friendly_message
108
- "Coverage data stale: #{message}" + build_details
107
+ "Coverage data stale: #{message}" + formatter.format_single_file_details(
108
+ file_path: @file_path,
109
+ file_mtime: @file_mtime,
110
+ src_len: @src_len,
111
+ cov_len: @cov_len
112
+ )
109
113
  end
110
114
 
111
115
  private def default_message
112
116
  fp = file_path || 'file'
113
117
  "Coverage data appears stale for #{fp}"
114
118
  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
119
  end
132
120
 
133
121
  # Project-level stale coverage (global) — coverage timestamp older than
134
122
  # one or more source files, or new tracked files missing from coverage.
135
123
  class CoverageDataProjectStaleError < CoverageDataError
136
- attr_reader :cov_timestamp, :newer_files, :missing_files, :deleted_files, :resultset_path
124
+ include StalenessFormatterMixin
125
+
126
+ attr_reader :cov_timestamp, :newer_files, :missing_files, :deleted_files,
127
+ :length_mismatch_files, :unreadable_files, :resultset_path
137
128
 
138
129
  def initialize(message = nil, original_error = nil, cov_timestamp: nil, newer_files: [],
139
- missing_files: [], deleted_files: [], resultset_path: nil)
130
+ missing_files: [], deleted_files: [], length_mismatch_files: [], unreadable_files: [],
131
+ resultset_path: nil)
140
132
  super(message, original_error)
141
133
  @cov_timestamp = cov_timestamp
142
134
  @newer_files = Array(newer_files)
143
135
  @missing_files = Array(missing_files)
144
136
  @deleted_files = Array(deleted_files)
137
+ @length_mismatch_files = Array(length_mismatch_files)
138
+ @unreadable_files = Array(unreadable_files)
145
139
  @resultset_path = resultset_path
146
140
  end
147
141
 
148
142
  def user_friendly_message
149
143
  base = "Coverage data stale (project): #{message || default_message}"
150
- base + build_details
144
+ base + formatter.format_project_details(
145
+ newer_files: @newer_files,
146
+ missing_files: @missing_files,
147
+ deleted_files: @deleted_files,
148
+ length_mismatch_files: @length_mismatch_files,
149
+ unreadable_files: @unreadable_files
150
+ )
151
151
  end
152
152
 
153
153
  private def default_message
154
154
  'Coverage data appears stale for project'
155
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
156
  end
180
157
 
181
158
  # Command line usage errors
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../output_chars'
5
+
6
+ module CovLoupe
7
+ module Formatters
8
+ # Maps format symbols to their required libraries
9
+ # Only loaded when the format is actually used
10
+ FORMAT_REQUIRES = {
11
+ yaml: 'yaml',
12
+ amazing_print: 'amazing_print'
13
+ }.freeze
14
+
15
+ # Ensures required libraries are loaded for the given format
16
+ def self.ensure_requirements_for(format)
17
+ requirement = FORMAT_REQUIRES[format]
18
+ require requirement if requirement
19
+ end
20
+
21
+ # Formats an object using the specified format.
22
+ #
23
+ # @param obj [Object] The object to format
24
+ # @param format [Symbol] Format type (:table, :json, :pretty_json, :yaml, :amazing_print)
25
+ # @param output_chars [Symbol] Output character mode (:default, :fancy, :ascii)
26
+ # @return [String] Formatted output
27
+ def self.format(obj, format, output_chars: :default)
28
+ ensure_requirements_for(format)
29
+ ascii_mode = OutputChars.ascii_mode?(output_chars)
30
+
31
+ case format
32
+ when :table
33
+ # Pass through - table formatting handled elsewhere with its own output_chars
34
+ obj
35
+ when :json
36
+ ascii_mode ? JSON.generate(obj, ascii_only: true) : obj.to_json
37
+ when :pretty_json
38
+ ascii_mode ? JSON.pretty_generate(obj, ascii_only: true) : JSON.pretty_generate(obj)
39
+ when :yaml
40
+ format_yaml(obj, ascii_mode: ascii_mode)
41
+ when :amazing_print
42
+ require 'amazing_print'
43
+ result = obj.ai
44
+ # AmazingPrint doesn't have native ASCII mode; convert if needed
45
+ ascii_mode ? OutputChars.convert(result, :ascii) : result
46
+ else
47
+ raise ArgumentError, "Unknown format: #{format}"
48
+ end
49
+ rescue LoadError => e
50
+ gem_name = e.message[/-- (\S+)/, 1] || 'required gem'
51
+ raise LoadError, "The #{format} format requires the '#{gem_name}' gem. " \
52
+ "Install it with: gem install #{gem_name}"
53
+ end
54
+
55
+ # Formats an object as YAML, with optional ASCII-only output.
56
+ #
57
+ # YAML doesn't have a native ASCII-only mode, so for ASCII mode we:
58
+ # 1. Generate standard YAML
59
+ # 2. Convert any non-ASCII characters using OutputChars.convert
60
+ #
61
+ # This approach preserves YAML structure while ensuring ASCII-only output.
62
+ # Note: This may affect string values containing Unicode, but YAML structure
63
+ # (which is ASCII) remains valid.
64
+ #
65
+ # @param obj [Object] The object to format
66
+ # @param ascii_mode [Boolean] If true, ensure ASCII-only output
67
+ # @return [String] YAML-formatted output
68
+ def self.format_yaml(obj, ascii_mode: false)
69
+ require 'yaml'
70
+ yaml = obj.to_yaml
71
+ ascii_mode ? OutputChars.convert(yaml, :ascii) : yaml
72
+ end
73
+ private_class_method :format_yaml
74
+ end
75
+ end
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../output_chars'
4
+
3
5
  module CovLoupe
4
6
  module Formatters
5
7
  class SourceFormatter
6
- def initialize(color_enabled: true)
8
+ def initialize(color_enabled: true, output_chars: :default)
7
9
  @color_enabled = color_enabled
10
+ @output_chars = output_chars
8
11
  end
9
12
 
10
13
  def format_source_for(model, path, mode: nil, context: 2)
@@ -21,8 +24,9 @@ module CovLoupe
21
24
  format_source_rows(rows)
22
25
  rescue ArgumentError
23
26
  raise
24
- rescue
27
+ rescue => e
25
28
  # If any unexpected formatting/indexing error occurs, avoid crashing the CLI
29
+ CovLoupe.logger.safe_log("SourceFormatter#format_source_for error for path '#{abs}': #{e.class} - #{e.message}")
26
30
  '[source not available]'
27
31
  end
28
32
  end
@@ -59,10 +63,14 @@ module CovLoupe
59
63
  end
60
64
 
61
65
  def format_source_rows(rows)
66
+ # Use ASCII-safe markers when output_chars is :ascii
67
+ check_mark = OutputChars.ascii_mode?(@output_chars) ? '+' : "\u2713" # ✓
68
+ miss_mark = OutputChars.ascii_mode?(@output_chars) ? '-' : "\u00B7" # ·
69
+
62
70
  marker = ->(covered, _hits) do
63
71
  case covered
64
- when true then colorize('✓', :green)
65
- when false then colorize('·', :red)
72
+ when true then colorize(check_mark, :green)
73
+ when false then colorize(miss_mark, :red)
66
74
  else colorize(' ', :dim)
67
75
  end
68
76
  end
@@ -73,7 +81,9 @@ module CovLoupe
73
81
 
74
82
  rows.each do |r|
75
83
  m = marker.call(r['covered'], r['hits'])
76
- lines << format('%6d %2s | %s', r['line'], m, r['code'])
84
+ # Convert source code to ASCII when in ASCII mode
85
+ code = OutputChars.convert(r['code'], @output_chars)
86
+ lines << format('%6d %2s | %s', r['line'], m, code)
77
87
  end
78
88
  lines.join("\n")
79
89
  end
@@ -89,7 +99,7 @@ module CovLoupe
89
99
  out.join("\n")
90
100
  end
91
101
 
92
- attr_reader :color_enabled
102
+ attr_reader :color_enabled, :output_chars
93
103
 
94
104
  private def fetch_raw(model, path)
95
105
  @raw_cache ||= {}
@@ -97,7 +107,8 @@ module CovLoupe
97
107
 
98
108
  raw = model.raw_for(path)
99
109
  @raw_cache[path] = raw
100
- rescue
110
+ rescue => e
111
+ CovLoupe.logger.safe_log("SourceFormatter#fetch_raw error for path '#{path}': #{e.class} - #{e.message}")
101
112
  nil
102
113
  end
103
114
 
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../output_chars'
4
+
5
+ module CovLoupe
6
+ # General-purpose table formatter with box-drawing or ASCII characters
7
+ # Used by commands to create consistent formatted output
8
+ class TableFormatter
9
+ # Format data as a table with box-drawing or ASCII characters.
10
+ # @param headers [Array<String>] Column headers
11
+ # @param rows [Array<Array>] Data rows (each row is an array of cell values)
12
+ # @param alignments [Array<Symbol>] Column alignments (:left, :right, :center)
13
+ # @param output_chars [Symbol] Output character mode (:default, :fancy, or :ascii)
14
+ # @return [String] Formatted table
15
+ def self.format(headers:, rows:, alignments: nil, output_chars: :default)
16
+ return 'No data to display' if rows.empty?
17
+
18
+ # Resolve mode and get appropriate charset
19
+ resolved_mode = OutputChars.resolve_mode(output_chars)
20
+ charset = OutputChars.charset_for(resolved_mode)
21
+
22
+ # Convert cell contents to ASCII if needed
23
+ convert = ->(text) { OutputChars.convert(text.to_s, resolved_mode) }
24
+
25
+ alignments ||= [:left] * headers.size
26
+ converted_headers = headers.map(&convert)
27
+ converted_rows = rows.map { |row| row.map(&convert) }
28
+ all_rows = [converted_headers] + converted_rows
29
+
30
+ # Calculate column widths
31
+ widths = headers.size.times.map do |col|
32
+ all_rows.map { |row| row[col].to_s.length }.max
33
+ end
34
+
35
+ lines = []
36
+ lines << border_line(widths, charset[:top_left], charset[:top_tee], charset[:top_right], charset)
37
+ lines << data_row(converted_headers, widths, alignments, charset)
38
+ lines << border_line(widths, charset[:left_tee], charset[:cross], charset[:right_tee], charset)
39
+ converted_rows.each { |row| lines << data_row(row, widths, alignments, charset) }
40
+ lines << border_line(widths, charset[:bottom_left], charset[:bottom_tee], charset[:bottom_right],
41
+ charset)
42
+
43
+ lines.join("\n")
44
+ end
45
+
46
+ # Format a single key-value table (vertical layout)
47
+ # @param data [Hash] Key-value pairs
48
+ # @param output_chars [Symbol] Output character mode (:default, :fancy, or :ascii)
49
+ # @return [String] Formatted table
50
+ def self.format_vertical(data, output_chars: :default)
51
+ rows = data.map { |k, v| [k.to_s, v.to_s] }
52
+ format(headers: ['Key', 'Value'], rows: rows, alignments: [:left, :left], output_chars: output_chars)
53
+ end
54
+
55
+ private_class_method def self.border_line(widths, left, mid, right, charset)
56
+ h = charset[:horizontal]
57
+ segments = widths.map { |w| h * (w + 2) }
58
+ left + segments.join(mid) + right
59
+ end
60
+
61
+ private_class_method def self.data_row(cells, widths, alignments, charset)
62
+ v = charset[:vertical]
63
+ formatted = cells.each_with_index.map do |cell, i|
64
+ align_cell(cell.to_s, widths[i], alignments[i])
65
+ end
66
+ "#{v} #{formatted.join(" #{v} ")} #{v}"
67
+ end
68
+
69
+ private_class_method def self.align_cell(content, width, alignment)
70
+ case alignment
71
+ when :right
72
+ content.rjust(width)
73
+ when :center
74
+ content.center(width)
75
+ else # :left
76
+ content.ljust(width)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load all CovLoupe components including CLI, MCP server, and all tools.
4
+ # This file is used by the test suite (spec/spec_helper.rb) to ensure all
5
+ # components are loaded for testing.
6
+ #
7
+ # For selective loading at runtime:
8
+ # - Use all_cli.rb for CLI mode (loads optparse + CLI)
9
+ # - Use all_mcp.rb for MCP mode (loads MCP gem + tools)
10
+ #
11
+ # Library users should use `require 'cov_loupe'` instead, which loads only the core
12
+ # components (CoverageModel, errors, utilities) without the CLI/MCP overhead.
13
+
14
+ require_relative 'all_cli'
15
+ require_relative 'all_mcp'
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load CLI-specific components.
4
+ # Used when CovLoupe.run detects CLI mode.
5
+
6
+ require_relative '../../cov_loupe' # Core library components (lib/cov_loupe.rb)
7
+
8
+ # CLI dependencies
9
+ require 'optparse'
10
+ require_relative '../cli'
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load MCP server-specific components including all tools.
4
+ # Used when CovLoupe.run detects MCP mode.
5
+
6
+ require_relative '../../cov_loupe' # Core library components (lib/cov_loupe.rb)
7
+
8
+ # MCP server dependencies
9
+ require 'mcp'
10
+ require 'mcp/server/transports/stdio_transport'
11
+ require_relative '../config/config_parser'
12
+ require_relative '../base_tool'
13
+ require_relative '../tools/coverage_raw_tool'
14
+ require_relative '../tools/coverage_summary_tool'
15
+ require_relative '../tools/uncovered_lines_tool'
16
+ require_relative '../tools/coverage_detailed_tool'
17
+ require_relative '../tools/list_tool'
18
+ require_relative '../tools/coverage_totals_tool'
19
+ require_relative '../tools/coverage_table_tool'
20
+ require_relative '../tools/validate_tool'
21
+ require_relative '../tools/version_tool'
22
+ require_relative '../tools/help_tool'
23
+ require_relative '../mcp_server'
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ require_relative '../errors/errors'
7
+
8
+ module CovLoupe
9
+ class ResultsetLoader
10
+ Result = Struct.new(:coverage_map, :timestamp, :suite_names, keyword_init: true)
11
+ SuiteEntry = Struct.new(:name, :coverage, :timestamp, keyword_init: true)
12
+
13
+ def self.load(resultset_path:, logger: nil)
14
+ logger ||= CovLoupe.logger
15
+ new(resultset_path: resultset_path, logger: logger).load
16
+ end
17
+
18
+ def initialize(resultset_path:, logger:)
19
+ @resultset_path = resultset_path
20
+ @logger = logger
21
+ end
22
+
23
+ def load
24
+ raw = JSON.parse(File.read(@resultset_path))
25
+
26
+ suites = extract_suite_entries(raw)
27
+ if suites.empty?
28
+ raise CoverageDataError, "No test suite with coverage data found in resultset file: #{@resultset_path}"
29
+ end
30
+
31
+ coverage_map = build_coverage_map(suites)
32
+ Result.new(
33
+ coverage_map: coverage_map,
34
+ timestamp: compute_combined_timestamp(suites),
35
+ suite_names: suites.map(&:name)
36
+ )
37
+ end
38
+
39
+ private def extract_suite_entries(raw)
40
+ raw
41
+ .select { |_, data| data.is_a?(Hash) && data.key?('coverage') && !data['coverage'].nil? }
42
+ .map do |name, data|
43
+ SuiteEntry.new(
44
+ name: name.to_s,
45
+ coverage: normalize_suite_coverage(data['coverage'], suite_name: name),
46
+ timestamp: normalize_coverage_timestamp(data['timestamp'], data['created_at'])
47
+ )
48
+ end
49
+ end
50
+
51
+ private def build_coverage_map(suites)
52
+ return suites.first&.coverage if suites.length == 1
53
+
54
+ merge_suite_coverages(suites)
55
+ end
56
+
57
+ private def normalize_suite_coverage(coverage, suite_name:)
58
+ unless coverage.is_a?(Hash)
59
+ raise CoverageDataError, "Invalid coverage data structure for suite #{suite_name.inspect} in resultset file: #{@resultset_path}"
60
+ end
61
+
62
+ needs_adaptation = coverage.values.any? { |value| value.is_a?(Array) }
63
+ return coverage unless needs_adaptation
64
+
65
+ coverage.transform_values do |value|
66
+ value.is_a?(Array) ? { 'lines' => value } : value
67
+ end
68
+ end
69
+
70
+ private def merge_suite_coverages(suites)
71
+ require_simplecov_for_merge!
72
+ log_duplicate_suite_names(suites)
73
+
74
+ suites.reduce(nil) do |memo, suite|
75
+ coverage = suite.coverage
76
+ memo ?
77
+ SimpleCov::Combine.combine(SimpleCov::Combine::ResultsCombiner, memo, coverage) :
78
+ coverage
79
+ end
80
+ end
81
+
82
+ private def require_simplecov_for_merge!
83
+ require 'simplecov'
84
+ rescue LoadError
85
+ raise CoverageDataError, "Multiple coverage suites detected in #{@resultset_path}, but the simplecov gem could not be loaded. Install simplecov to enable suite merging."
86
+ end
87
+
88
+ private def log_duplicate_suite_names(suites)
89
+ grouped = suites.group_by(&:name)
90
+ duplicates = grouped.select { |_, entries| entries.length > 1 }.keys
91
+ return if duplicates.empty?
92
+
93
+ message = "Merging duplicate coverage suites for #{duplicates.join(', ')}"
94
+ @logger.safe_log(message)
95
+ end
96
+
97
+ private def compute_combined_timestamp(suites)
98
+ suites.map(&:timestamp).compact.max.to_i
99
+ end
100
+
101
+ private def normalize_coverage_timestamp(timestamp_value, created_at_value)
102
+ raw = timestamp_value.nil? ? created_at_value : timestamp_value
103
+ return log_missing_timestamp if raw.nil?
104
+
105
+ timestamp = case raw
106
+ when Integer
107
+ raw
108
+ when Float, Time
109
+ raw.to_i
110
+ when String
111
+ str = raw.strip
112
+ if str.match?(/\A-?\d+(\.\d+)?\z/)
113
+ # Matches optional leading "-", digits, and an optional fractional part.
114
+ str.to_f.to_i
115
+ elsif str.empty?
116
+ 0
117
+ else
118
+ Time.parse(str).to_i
119
+ end
120
+ else
121
+ log_timestamp_warning(raw)
122
+ return 0
123
+ end
124
+
125
+ timestamp = [timestamp.to_i, 0].max # change negative numbers to zero
126
+ log_missing_timestamp(raw) if timestamp.zero? # but log the original value
127
+ timestamp
128
+ rescue => e
129
+ log_timestamp_warning(raw, e)
130
+ 0
131
+ end
132
+
133
+ private def log_missing_timestamp(raw_value = nil)
134
+ message = 'Coverage timestamp missing, defaulting to 0. ' \
135
+ 'Time-based staleness checks will be disabled.'
136
+ message = "#{message} (value: #{raw_value.inspect})" if raw_value
137
+ @logger.safe_log(message)
138
+ 0
139
+ end
140
+
141
+ private def log_timestamp_warning(raw_value, error = nil)
142
+ message = "Coverage resultset timestamp could not be parsed: #{raw_value.inspect}"
143
+ message = "#{message} (#{error.message})" if error
144
+ @logger.safe_log(message)
145
+ end
146
+ end
147
+ end
@@ -11,14 +11,15 @@ module CovLoupe
11
11
  server = ::MCP::Server.new(
12
12
  name: 'cov-loupe',
13
13
  version: CovLoupe::VERSION,
14
- tools: toolset
14
+ tools: toolset,
15
+ server_context: context
15
16
  )
16
17
  ::MCP::Server::Transports::StdioTransport.new(server).open
17
18
  end
18
19
  end
19
20
 
20
21
  TOOLSET = [
21
- Tools::AllFilesCoverageTool,
22
+ Tools::ListTool,
22
23
  Tools::CoverageDetailedTool,
23
24
  Tools::CoverageRawTool,
24
25
  Tools::CoverageSummaryTool,