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
@@ -18,13 +18,6 @@ module CovLoupe
18
18
  'uncovered' => :uncovered
19
19
  }.freeze
20
20
 
21
- STALENESS_MAP = {
22
- 'o' => :off,
23
- 'off' => :off,
24
- 'e' => :error,
25
- 'error' => :error
26
- }.freeze
27
-
28
21
  ERROR_MODE_MAP = {
29
22
  'off' => :off,
30
23
  'o' => :off,
@@ -44,9 +37,26 @@ module CovLoupe
44
37
  'pretty-json' => :pretty_json,
45
38
  'y' => :yaml,
46
39
  'yaml' => :yaml,
47
- 'a' => :awesome_print,
48
- 'awesome_print' => :awesome_print,
49
- 'ap' => :awesome_print
40
+ 'a' => :amazing_print,
41
+ 'awesome_print' => :amazing_print,
42
+ 'ap' => :amazing_print,
43
+ 'amazing_print' => :amazing_print
44
+ }.freeze
45
+
46
+ MODE_MAP = {
47
+ 'cli' => :cli,
48
+ 'c' => :cli,
49
+ 'mcp' => :mcp,
50
+ 'm' => :mcp
51
+ }.freeze
52
+
53
+ OUTPUT_CHARS_MAP = {
54
+ 'd' => :default,
55
+ 'default' => :default,
56
+ 'f' => :fancy,
57
+ 'fancy' => :fancy,
58
+ 'a' => :ascii,
59
+ 'ascii' => :ascii
50
60
  }.freeze
51
61
 
52
62
  module_function def normalize_sort_order(value, strict: true)
@@ -70,19 +80,6 @@ module CovLoupe
70
80
  nil
71
81
  end
72
82
 
73
- # Normalize stale mode value.
74
- # @param value [String, Symbol] The value to normalize
75
- # @param strict [Boolean] If true, raises on invalid value; if false, returns nil
76
- # @return [Symbol, nil] The normalized symbol or nil if invalid and not strict
77
- # @raise [OptionParser::InvalidArgument] If strict and value is invalid
78
- module_function def normalize_staleness(value, strict: true)
79
- normalized = STALENESS_MAP[value.to_s.downcase]
80
- return normalized if normalized
81
- raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
82
-
83
- nil
84
- end
85
-
86
83
  # Normalize error mode value.
87
84
  # @param value [String, Symbol, nil] The value to normalize
88
85
  # @param strict [Boolean] If true, raises on invalid value; if false, returns default
@@ -92,6 +89,7 @@ module CovLoupe
92
89
  module_function def normalize_error_mode(value, strict: true, default: :log)
93
90
  normalized = ERROR_MODE_MAP[value.to_s.downcase]
94
91
  return normalized if normalized
92
+
95
93
  raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
96
94
 
97
95
  default
@@ -103,11 +101,44 @@ module CovLoupe
103
101
  # @return [Symbol, nil] The normalized symbol or nil if invalid and not strict
104
102
  # @raise [OptionParser::InvalidArgument] If strict and value is invalid
105
103
  module_function def normalize_format(value, strict: true)
106
- normalized = FORMAT_MAP[value.to_s.downcase]
104
+ # Try exact match first (preserves case-sensitive 'J' for pretty_json)
105
+ normalized = FORMAT_MAP[value.to_s] || FORMAT_MAP[value.to_s.downcase]
107
106
  return normalized if normalized
107
+
108
108
  raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
109
109
 
110
110
  nil
111
111
  end
112
+
113
+ # Normalize mode value (cli or mcp).
114
+ # @param value [String, Symbol] The value to normalize
115
+ # @param strict [Boolean] If true, raises on invalid value; if false, returns default
116
+ # @param default [Symbol] The default value to return if invalid and not strict
117
+ # @return [Symbol] The normalized symbol (:cli or :mcp)
118
+ # @raise [OptionParser::InvalidArgument] If strict and value is invalid
119
+ module_function def normalize_mode(value, strict: true, default: :cli)
120
+ normalized = MODE_MAP[value.to_s.downcase]
121
+ return normalized if normalized
122
+
123
+ raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
124
+
125
+ default
126
+ end
127
+
128
+ # Normalize output_chars value.
129
+ # Controls ASCII vs Unicode (fancy) output for tables and text.
130
+ # @param value [String, Symbol] The value to normalize
131
+ # @param strict [Boolean] If true, raises on invalid value; if false, returns default
132
+ # @param default [Symbol] The default value to return if invalid and not strict
133
+ # @return [Symbol] The normalized symbol (:default, :fancy, or :ascii)
134
+ # @raise [OptionParser::InvalidArgument] If strict and value is invalid
135
+ module_function def normalize_output_chars(value, strict: true, default: :default)
136
+ normalized = OUTPUT_CHARS_MAP[value.to_s.downcase]
137
+ return normalized if normalized
138
+
139
+ raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
140
+
141
+ default
142
+ end
112
143
  end
113
144
  end
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'option_normalizers'
4
- require_relative 'version'
4
+ require_relative '../version'
5
+ require_relative 'boolean_type'
6
+ require_relative '../errors/errors'
5
7
 
6
8
  module CovLoupe
7
9
  class OptionParserBuilder
8
10
  HORIZONTAL_RULE = '-' * 79
9
- SUBCOMMANDS = %w[list summary raw uncovered detailed totals validate version].freeze
10
11
 
11
12
  attr_reader :config
12
13
 
@@ -25,11 +26,14 @@ module CovLoupe
25
26
  end
26
27
 
27
28
  private def configure_banner(parser)
29
+ gem_root = File.expand_path('../..', __dir__)
28
30
  parser.banner = <<~BANNER
29
31
  #{HORIZONTAL_RULE}
30
- Usage: cov-loupe [options] [subcommand] [args] (default subcommand: list)
31
- Repository: https://github.com/keithrbennett/cov-loupe
32
- Version: #{CovLoupe::VERSION}
32
+ Usage: cov-loupe [options] [subcommand] [args] (default subcommand: list)
33
+ Repository: https://github.com/keithrbennett/cov-loupe
34
+ Documentation (Web): https://keithrbennett.github.io/cov-loupe/
35
+ Documentation (Local): #{gem_root}/**/*.md
36
+ Version: #{CovLoupe::VERSION}
33
37
  #{HORIZONTAL_RULE}
34
38
 
35
39
  BANNER
@@ -45,7 +49,7 @@ module CovLoupe
45
49
  totals Show aggregated line totals and average %
46
50
  uncovered <path> Show uncovered lines and a summary
47
51
  validate <file> Evaluate coverage policy from file (exit 0=pass, 1=fail, 2=error)
48
- validate -e <code> Evaluate coverage policy from code string
52
+ validate -i <code> Evaluate coverage policy from code string
49
53
  version Show version information
50
54
 
51
55
  SUBCOMMANDS
@@ -63,7 +67,7 @@ module CovLoupe
63
67
  end
64
68
  parser.on(
65
69
  '-f', '--format FORMAT', String,
66
- 'Output format: t[able]|j[son]|pretty-json|y[aml]|a[wesome-print] (default: table)'
70
+ 'Output format: t[able]|j[son]|pretty-json|y[aml]|a[mazing_print] (default: table)'
67
71
  ) do |value|
68
72
  config.format = normalize_format(value)
69
73
  end
@@ -77,38 +81,52 @@ module CovLoupe
77
81
  end
78
82
  parser.on('-c', '--context-lines N', Integer,
79
83
  'Context lines around uncovered lines (non-negative, default: 2)') do |value|
84
+ raise UsageError, 'Context lines cannot be negative' if value.negative?
85
+
80
86
  config.source_context = value
81
87
  end
82
- parser.on('--color', 'Enable ANSI colors for source output') { config.color = true }
83
- parser.on('--no-color', 'Disable ANSI colors') { config.color = false }
84
- parser.on('-S', '--staleness MODE', String,
85
- 'Staleness detection: o[ff]|e[rror] (default off)') do |value|
86
- config.staleness = normalize_staleness(value)
88
+ parser.on('-C', '--color BOOLEAN', BooleanType::IS_BOOLEAN_STRING_VALUE,
89
+ 'Enable/disable ANSI colors for source output (default: true). ' \
90
+ "Accepts: #{BooleanType::BOOLEAN_VALUES_DISPLAY_STRING}") do |value|
91
+ config.color = BooleanType.parse(value)
92
+ end
93
+ parser.on('-S', '--raise-on-stale BOOLEAN', BooleanType::IS_BOOLEAN_STRING_VALUE,
94
+ 'Raise error if coverage is stale (default: false). ' \
95
+ "Accepts: #{BooleanType::BOOLEAN_VALUES_DISPLAY_STRING}") do |value|
96
+ config.raise_on_stale = BooleanType.parse(value)
87
97
  end
88
98
  parser.on('-g', '--tracked-globs x,y,z', Array,
89
- 'Globs for filtering files (list/totals subcommands)') do |value|
99
+ 'Used to exclude unwanted results and/or include files with or without coverage data',
100
+ 'Default: [] (shows all files in resultset)',
101
+ 'Best practice: match your SimpleCov track_files patterns',
102
+ 'Example: --tracked-globs lib/**/*.rb,app/**/*.rb') do |value|
90
103
  config.tracked_globs = value
91
104
  end
92
105
  parser.on('-h', '--help', 'Show help') do
93
106
  puts parser
94
- gem_root = File.expand_path('../..', __dir__)
95
- puts "\nFor more detailed help, consult README.md and docs/user/**/*.md"
96
- puts "in the installed gem at: #{gem_root}"
97
107
  exit 0
98
108
  end
99
109
  parser.on('-l', '--log-file PATH', String,
100
110
  'Log file path (default ./cov_loupe.log, use stdout/stderr for streams)') do |value|
101
111
  config.log_file = value
102
112
  end
103
- parser.on('--error-mode MODE', String,
113
+ parser.on('-m', '--mode MODE', String,
114
+ 'Execution mode: cli|mcp (default: cli)') do |value|
115
+ config.mode = normalize_mode(value)
116
+ end
117
+ parser.on('-e', '--error-mode MODE', String,
104
118
  'Error handling mode: o[ff]|l[og]|d[ebug] (default log). ' \
105
119
  'off (silent), log (log errors to file), debug (verbose with backtraces)') do |value|
106
120
  config.error_mode = normalize_error_mode(value)
107
121
  end
108
- parser.on('--force-cli', 'Force CLI mode (useful in scripts where auto-detection fails)') do
109
- # This flag is mainly for mode detection - no action needed here
122
+ parser.on('-O', '--output-chars MODE', String,
123
+ 'Output character mode: d[efault]|f[ancy]|a[scii] (default: default). ' \
124
+ 'default: UTF-8 encoding uses fancy (Unicode), else ascii. ' \
125
+ 'fancy: use Unicode box-drawing and symbols. ' \
126
+ 'ascii: use ASCII-only characters (0x00-0x7F).') do |value|
127
+ config.output_chars = normalize_output_chars(value)
110
128
  end
111
- parser.on('-v', '--version', 'Show version information and exit') do
129
+ parser.on('-v', '--version', 'Show version information and exit.') do
112
130
  config.show_version = true
113
131
  end
114
132
  end
@@ -132,10 +150,6 @@ module CovLoupe
132
150
  OptionNormalizers.normalize_source_mode(value, strict: true)
133
151
  end
134
152
 
135
- private def normalize_staleness(value)
136
- OptionNormalizers.normalize_staleness(value, strict: true)
137
- end
138
-
139
153
  private def normalize_error_mode(value)
140
154
  OptionNormalizers.normalize_error_mode(value, strict: true)
141
155
  end
@@ -143,5 +157,13 @@ module CovLoupe
143
157
  private def normalize_format(value)
144
158
  OptionNormalizers.normalize_format(value, strict: true)
145
159
  end
160
+
161
+ private def normalize_mode(value)
162
+ OptionNormalizers.normalize_mode(value, strict: true)
163
+ end
164
+
165
+ private def normalize_output_chars(value)
166
+ OptionNormalizers.normalize_output_chars(value, strict: true)
167
+ end
146
168
  end
147
169
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CovLoupe
4
+ # Provides coverage data transformations and calculations.
5
+ # Handles summary statistics, uncovered line identification, and detailed line-by-line analysis.
6
+ class CoverageCalculator
7
+ # Calculates coverage summary statistics from a coverage array.
8
+ #
9
+ # @param coverage_lines [Array<Integer, nil>] SimpleCov coverage array where each element
10
+ # represents a line: Integer for hit count, nil for non-code lines
11
+ # @return [Hash] summary with 'covered', 'total', and 'percentage' keys
12
+ def self.summary(coverage_lines)
13
+ total = 0
14
+ covered = 0
15
+ coverage_lines.compact.each do |hits|
16
+ total += 1
17
+ covered += 1 if hits.to_i > 0
18
+ end
19
+ percentage = total <= 0 ? nil : (covered.to_f / total * 100.0).round(2)
20
+ { 'covered' => covered, 'total' => total, 'percentage' => percentage }
21
+ end
22
+
23
+ # Identifies uncovered line numbers from a coverage array.
24
+ #
25
+ # @param coverage_lines [Array<Integer, nil>] SimpleCov coverage array
26
+ # @return [Array<Integer>] array of uncovered line numbers (1-indexed)
27
+ def self.uncovered(coverage_lines)
28
+ out = []
29
+
30
+ coverage_lines.each_with_index do |hits, i|
31
+ next if hits.nil?
32
+
33
+ out << (i + 1) if hits.to_i.zero?
34
+ end
35
+ out
36
+ end
37
+
38
+ # Generates detailed line-by-line coverage information.
39
+ #
40
+ # @param coverage_lines [Array<Integer, nil>] SimpleCov coverage array
41
+ # @return [Array<Hash>] array of hashes with 'line', 'hits', and 'covered' keys
42
+ def self.detailed(coverage_lines)
43
+ rows = []
44
+ coverage_lines.each_with_index do |hits, i|
45
+ next if hits.nil?
46
+
47
+ h = hits.to_i
48
+ rows << { 'line' => i + 1, 'hits' => h, 'covered' => h.positive? }
49
+ end
50
+ rows
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CovLoupe
4
+ require_relative '../staleness/stale_status'
5
+
6
+ # Reports files with coverage below a specified threshold.
7
+ # Useful for displaying low coverage files after test runs.
8
+ #
9
+ # @example Basic usage in spec_helper.rb
10
+ # SimpleCov.at_exit do
11
+ # SimpleCov.result.format!
12
+ # report = CovLoupe::CoverageReporter.report(threshold: 80, count: 5)
13
+ # puts report if report
14
+ # end
15
+ #
16
+ # @example With custom resultset path
17
+ # CovLoupe::CoverageReporter.report(
18
+ # threshold: 80,
19
+ # count: 5,
20
+ # resultset: 'custom/coverage/.resultset.json'
21
+ # )
22
+ #
23
+ # @example With custom project root
24
+ # CovLoupe::CoverageReporter.report(
25
+ # threshold: 80,
26
+ # count: 5,
27
+ # root: '/path/to/project'
28
+ # )
29
+ #
30
+ module CoverageReporter
31
+ module_function def report(threshold: 80, count: 5, model: nil, root: nil, resultset: nil)
32
+ # Determine default root from SimpleCov if available
33
+ default_root = defined?(SimpleCov) ? SimpleCov.root : '.'
34
+
35
+ # Determine default resultset from SimpleCov if available
36
+ default_resultset = if defined?(SimpleCov)
37
+ File.join(SimpleCov.root, SimpleCov.coverage_dir, '.resultset.json')
38
+ end
39
+
40
+ model ||= CoverageModel.new(
41
+ root: root || default_root,
42
+ resultset: resultset || default_resultset
43
+ )
44
+ list_result = model.list(sort_order: :ascending)
45
+ file_list = list_result['files']
46
+ .select { |f| f['percentage'] && f['percentage'] < threshold }
47
+ .first(count)
48
+ file_list = model.relativize(file_list)
49
+
50
+ return nil if file_list.empty?
51
+
52
+ lines = ["\nLowest coverage files (< #{threshold}%):"]
53
+ file_list.each do |f|
54
+ label = f['file']
55
+ if StaleStatus.stale?(f['stale'])
56
+ label = "#{label} (stale: #{f['stale']})"
57
+ end
58
+ lines << format(' %5.1f%% %s', f['percentage'], label)
59
+ end
60
+ lines.join("\n")
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../staleness/stale_status'
4
+ require_relative '../output_chars'
5
+
6
+ module CovLoupe
7
+ # Formats coverage data as a table with box-drawing characters
8
+ # Extracted from CoverageModel to separate presentation from domain logic
9
+ class CoverageTableFormatter
10
+ # Format coverage rows as a table with box-drawing or ASCII characters.
11
+ #
12
+ # @param rows [Array<Hash>] Coverage rows with keys: 'file', 'percentage', 'covered', 'total', 'stale'
13
+ # @param output_chars [Symbol] Output character mode (:default, :fancy, or :ascii)
14
+ # @return [String] Formatted table with borders and summary
15
+ def self.format(rows, output_chars: :default)
16
+ return 'No coverage data found' 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 file paths and other string content to ASCII if needed
23
+ converted_rows = rows.map do |row|
24
+ row.merge('file' => OutputChars.convert(row['file'], resolved_mode))
25
+ end
26
+
27
+ widths = compute_table_widths(converted_rows)
28
+ lines = []
29
+ lines << border_line(widths, charset[:top_left], charset[:top_tee], charset[:top_right], charset)
30
+ lines << header_row(widths, charset)
31
+ lines << border_line(widths, charset[:left_tee], charset[:cross], charset[:right_tee], charset)
32
+ converted_rows.each { |file_data| lines << data_row(file_data, widths, charset) }
33
+ lines << border_line(widths, charset[:bottom_left], charset[:bottom_tee], charset[:bottom_right],
34
+ charset)
35
+ lines << summary_counts(converted_rows)
36
+ if converted_rows.any? { |f| StaleStatus.stale?(f['stale']) }
37
+ lines <<
38
+ 'Staleness: error, missing, newer, length_mismatch'
39
+ end
40
+ lines.join("\n")
41
+ end
42
+
43
+ # Calculate column widths based on data
44
+ #
45
+ # @param rows [Array<Hash>] Coverage rows
46
+ # @return [Hash] Width for each column (:file, :pct, :covered, :total, :stale)
47
+ private_class_method def self.compute_table_widths(rows)
48
+ max_file_length = rows.map { |f| f['file'].length }.max.to_i
49
+ file_width = [max_file_length, 'File'.length].max + 2
50
+ pct_width = 8
51
+ max_covered = rows.map { |f| f['covered'].to_s.length }.max
52
+ max_total = rows.map { |f| f['total'].to_s.length }.max
53
+ covered_width = [max_covered, 'Covered'.length].max + 2
54
+ total_width = [max_total, 'Total'.length].max + 2
55
+ max_stale_label = rows.map { |f| StaleStatus.stale?(f['stale']) ? f['stale'].to_s.length : 0 }.max.to_i
56
+ stale_width = [max_stale_label, 'Stale'.length].max
57
+ {
58
+ file: file_width,
59
+ pct: pct_width,
60
+ covered: covered_width,
61
+ total: total_width,
62
+ stale: stale_width
63
+ }
64
+ end
65
+
66
+ # Generate a border line for the table
67
+ #
68
+ # @param widths [Hash] Column widths
69
+ # @param left [String] Left edge character
70
+ # @param middle [String] Column separator character
71
+ # @param right [String] Right edge character
72
+ # @param charset [Hash] Character set for borders
73
+ # @return [String] Border line
74
+ private_class_method def self.border_line(widths, left, middle, right, charset)
75
+ h = charset[:horizontal]
76
+ h_line = ->(col_width) { h * (col_width + 2) }
77
+ left +
78
+ h_line.call(widths[:file]) +
79
+ middle + h_line.call(widths[:pct]) +
80
+ middle + h_line.call(widths[:covered]) +
81
+ middle + h_line.call(widths[:total]) +
82
+ middle + h_line.call(widths[:stale]) +
83
+ right
84
+ end
85
+
86
+ # Generate the header row
87
+ #
88
+ # @param widths [Hash] Column widths
89
+ # @param charset [Hash] Character set for borders
90
+ # @return [String] Header row
91
+ private_class_method def self.header_row(widths, charset)
92
+ v = charset[:vertical]
93
+ Kernel.format(
94
+ "#{v} %-#{widths[:file]}s #{v} %#{widths[:pct]}s #{v} %#{widths[:covered]}s " \
95
+ "#{v} %#{widths[:total]}s #{v} %#{widths[:stale]}s #{v}",
96
+ 'File', ' %', 'Covered', 'Total', 'Stale'.center(widths[:stale])
97
+ )
98
+ end
99
+
100
+ # Generate a data row for a single file
101
+ #
102
+ # @param file_data [Hash] Coverage data for one file
103
+ # @param widths [Hash] Column widths
104
+ # @param charset [Hash] Character set for borders
105
+ # @return [String] Data row
106
+ private_class_method def self.data_row(file_data, widths, charset)
107
+ fd = file_data
108
+ ws = widths
109
+ v = charset[:vertical]
110
+ is_stale = StaleStatus.stale?(fd['stale'])
111
+ stale_str = is_stale ? fd['stale'].to_s.center(ws[:stale]) : ''
112
+ pct_str = if fd['percentage']
113
+ Kernel.format("%#{ws[:pct] - 1}.2f%%", fd['percentage'])
114
+ else
115
+ 'n/a'.rjust(ws[:pct])
116
+ end
117
+
118
+ format_str = "#{v} %-#{ws[:file]}s #{v} %s #{v} %#{ws[:covered]}d #{v} %#{ws[:total]}d #{v} %#{ws[:stale]}s #{v}"
119
+ Kernel.format(format_str, fd['file'], pct_str, fd['covered'], fd['total'], stale_str)
120
+ end
121
+
122
+ # Generate summary counts footer
123
+ #
124
+ # @param rows [Array<Hash>] Coverage rows
125
+ # @return [String] Summary line
126
+ private_class_method def self.summary_counts(rows)
127
+ total = rows.length
128
+ stale_count = rows.count { |f| StaleStatus.stale?(f['stale']) }
129
+ ok_count = total - stale_count
130
+ "Files: total #{total}, ok #{ok_count}, stale #{stale_count}"
131
+ end
132
+ end
133
+ end
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'json'
4
4
  require_relative 'errors'
5
- require_relative 'util'
6
5
 
7
6
  module CovLoupe
8
7
  # Handles error reporting and logging with configurable behavior
@@ -32,7 +31,7 @@ module CovLoupe
32
31
  def handle_error(error, context: nil, reraise: true)
33
32
  log_error(error, context)
34
33
  if reraise
35
- raise error.is_a?(CovLoupe::Error) ? error : convert_standard_error(error)
34
+ raise error.is_a?(CovLoupe::Error) ? error : convert_standard_error(error, context: context)
36
35
  end
37
36
  end
38
37
 
@@ -48,18 +47,24 @@ module CovLoupe
48
47
  when Errno::EISDIR
49
48
  filename = extract_filename(error.message)
50
49
  NotAFileError.new("Expected file but found directory: #{filename}", error)
50
+ when Errno::EMFILE, Errno::ENOSPC, IOError
51
+ FileError.new(error.message, error)
52
+ when Errno::EROFS
53
+ FilePermissionError.new(error.message, error)
51
54
  when JSON::ParserError
52
55
  CoverageDataError.new("Invalid coverage data format: #{error.message}", error)
56
+ when EncodingError
57
+ CoverageDataError.new("Invalid encoding in coverage data: #{error.message}", error)
58
+ when RangeError
59
+ CoverageDataError.new("Numeric overflow or range error: #{error.message}", error)
53
60
  when TypeError
54
61
  CoverageDataError.new("Invalid coverage data structure: #{error.message}", error)
55
62
  when ArgumentError
56
63
  convert_argument_error(error, context)
57
64
  when NoMethodError
58
65
  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)
66
+ else # including RuntimeError
67
+ UnknownError.new(error.message, error)
63
68
  end
64
69
  end
65
70
 
@@ -100,40 +105,23 @@ module CovLoupe
100
105
  end
101
106
  end
102
107
 
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
108
 
126
109
  private def log_error(error, context)
127
110
  return unless log_errors?
128
111
 
129
112
  message = build_log_message(error, context)
130
- if logger
131
- logger.error(message)
132
- else
133
- CovUtil.log(message)
134
- end
113
+ active_logger = @logger || CovLoupe.logger
114
+ active_logger.error(message)
135
115
  end
136
116
 
117
+ # Build log message for error logging.
118
+ # NOTE: Log messages are NOT converted to ASCII in ASCII output mode.
119
+ # This is intentional because:
120
+ # - Log files are system/debugging artifacts, not user-facing output
121
+ # - The output_chars feature targets terminal errors/warnings, not log files
122
+ # - Converting would lose debugging information (exact file paths, error details)
123
+ # - Creates inconsistency between logged paths and actual filesystem paths
124
+ # - No user value since logs are developer artifacts
137
125
  private def build_log_message(error, context)
138
126
  context_suffix = context ? " in #{context}" : ''
139
127
  parts = ["Error#{context_suffix}: #{error.class}: #{error.message}"]