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,339 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'time'
4
- require 'json'
5
-
6
- require_relative 'util'
7
- require_relative 'errors'
8
- require_relative 'error_handler'
9
- require_relative 'staleness_checker'
10
- require_relative 'path_relativizer'
11
- require_relative 'resultset_loader'
12
-
13
- module CovLoupe
14
- class CoverageModel
15
- RELATIVIZER_SCALAR_KEYS = %w[file file_path].freeze
16
- RELATIVIZER_ARRAY_KEYS = %w[newer_files missing_files deleted_files].freeze
17
-
18
- attr_reader :relativizer
19
-
20
- # Create a CoverageModel
21
- #
22
- # Params:
23
- # - root: project root directory (default '.')
24
- # - resultset: path or directory to .resultset.json
25
- # - staleness: :off or :error (default :off). When :error, raises
26
- # stale errors if sources are newer than coverage or line counts mismatch.
27
- # - tracked_globs: only used for all_files project-level staleness.
28
- def initialize(root: '.', resultset: nil, staleness: :off, tracked_globs: nil)
29
- @root = File.absolute_path(root || '.')
30
- @resultset = resultset
31
- @relativizer = PathRelativizer.new(
32
- root: @root,
33
- scalar_keys: RELATIVIZER_SCALAR_KEYS,
34
- array_keys: RELATIVIZER_ARRAY_KEYS
35
- )
36
-
37
- load_coverage_data(resultset, staleness, tracked_globs)
38
- end
39
-
40
- # Returns { 'file' => <absolute_path>, 'lines' => [hits|nil,...] }
41
- def raw_for(path)
42
- file_abs, coverage_lines = coverage_data_for(path)
43
- { 'file' => file_abs, 'lines' => coverage_lines }
44
- end
45
-
46
- def relativize(payload)
47
- relativizer.relativize(payload)
48
- end
49
-
50
- # Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, 'percentage'=>} }
51
- def summary_for(path)
52
- file_abs, coverage_lines = coverage_data_for(path)
53
- { 'file' => file_abs, 'summary' => CovUtil.summary(coverage_lines) }
54
- end
55
-
56
- # Returns { 'file' => <absolute_path>, 'uncovered' => [line,...], 'summary' => {...} }
57
- def uncovered_for(path)
58
- file_abs, coverage_lines = coverage_data_for(path)
59
- {
60
- 'file' => file_abs,
61
- 'uncovered' => CovUtil.uncovered(coverage_lines),
62
- 'summary' => CovUtil.summary(coverage_lines)
63
- }
64
- end
65
-
66
- # Returns { 'file' => <absolute_path>, 'lines' => [{'line'=>,'hits'=>,'covered'=>},...], 'summary' => {...} }
67
- def detailed_for(path)
68
- file_abs, coverage_lines = coverage_data_for(path)
69
- {
70
- 'file' => file_abs,
71
- 'lines' => CovUtil.detailed(coverage_lines),
72
- 'summary' => CovUtil.summary(coverage_lines)
73
- }
74
- end
75
-
76
- # Returns [ { 'file' =>, 'covered' =>, 'total' =>, 'percentage' =>, 'stale' => }, ... ]
77
- def all_files(sort_order: :descending, check_stale: !@checker.off?, tracked_globs: nil)
78
- stale_checker = build_staleness_checker(mode: :off, tracked_globs: tracked_globs)
79
-
80
- rows = @cov.map do |abs_path, _data|
81
- begin
82
- coverage_lines = CovUtil.lookup_lines(@cov, abs_path)
83
- rescue FileError
84
- next
85
- end
86
-
87
- s = CovUtil.summary(coverage_lines)
88
- stale = stale_checker.stale_for_file?(abs_path, coverage_lines)
89
- {
90
- 'file' => abs_path,
91
- 'covered' => s['covered'],
92
- 'total' => s['total'],
93
- 'percentage' => s['percentage'],
94
- 'stale' => stale
95
- }
96
- end.compact
97
-
98
- rows = filter_rows_by_globs(rows, tracked_globs)
99
-
100
- if check_stale
101
- build_staleness_checker(mode: :error, tracked_globs: tracked_globs).check_project!(@cov)
102
- end
103
-
104
- sort_rows(rows, sort_order: sort_order)
105
- end
106
-
107
- def project_totals(tracked_globs: nil, check_stale: !@checker.off?)
108
- rows = all_files(sort_order: :ascending, check_stale: check_stale,
109
- tracked_globs: tracked_globs)
110
- totals_from_rows(rows)
111
- end
112
-
113
- def staleness_for(path)
114
- file_abs = File.absolute_path(path, @root)
115
- coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
116
- @checker.stale_for_file?(file_abs, coverage_lines)
117
- rescue => e
118
- CovUtil.safe_log("Failed to check staleness for #{path}: #{e.message}")
119
- false
120
- end
121
-
122
- # Returns formatted table string for all files coverage data
123
- def format_table(rows = nil, sort_order: :descending, check_stale: !@checker.off?,
124
- tracked_globs: nil)
125
- rows = prepare_rows(rows, sort_order: sort_order, check_stale: check_stale,
126
- tracked_globs: tracked_globs)
127
- return 'No coverage data found' if rows.empty?
128
-
129
- widths = compute_table_widths(rows)
130
- lines = []
131
- lines << border_line(widths, '┌', '┬', '┐')
132
- lines << header_row(widths)
133
- lines << border_line(widths, '├', '┼', '┤')
134
- rows.each { |file_data| lines << data_row(file_data, widths) }
135
- lines << border_line(widths, '└', '┴', '┘')
136
- lines << summary_counts(rows)
137
- if rows.any? { |f| f['stale'] }
138
- lines <<
139
- 'Staleness: M = Missing file, T = Timestamp (source newer), L = Line count mismatch'
140
- end
141
- lines.join("\n")
142
- end
143
-
144
- private def load_coverage_data(resultset, staleness, tracked_globs)
145
- rs = CovUtil.find_resultset(@root, resultset: resultset)
146
- loaded = ResultsetLoader.load(resultset_path: rs)
147
- coverage_map = loaded.coverage_map or raise(CoverageDataError, "No 'coverage' key found in resultset file: #{rs}")
148
-
149
- @cov = coverage_map.transform_keys { |k| File.absolute_path(k, @root) }
150
- @cov_timestamp = loaded.timestamp
151
-
152
- @checker = StalenessChecker.new(
153
- root: @root,
154
- resultset: @resultset,
155
- mode: staleness,
156
- tracked_globs: tracked_globs,
157
- timestamp: @cov_timestamp
158
- )
159
- rescue CovLoupe::Error
160
- raise # Re-raise our own errors as-is
161
- rescue => e
162
- raise ErrorHandler.new.convert_standard_error(e, context: :coverage_loading)
163
- end
164
-
165
- private def build_staleness_checker(mode:, tracked_globs:)
166
- StalenessChecker.new(
167
- root: @root,
168
- resultset: @resultset,
169
- mode: mode,
170
- tracked_globs: tracked_globs,
171
- timestamp: @cov_timestamp
172
- )
173
- end
174
-
175
- private def prepare_rows(rows, sort_order:, check_stale:, tracked_globs:)
176
- if rows.nil?
177
- all_files(sort_order: sort_order, check_stale: check_stale, tracked_globs: tracked_globs)
178
- else
179
- rows = sort_rows(rows.dup, sort_order: sort_order)
180
- filter_rows_by_globs(rows, tracked_globs)
181
- end
182
- end
183
-
184
- private def sort_rows(rows, sort_order: :descending)
185
- rows.sort do |a, b|
186
- pct_cmp = (sort_order == :descending) \
187
- ? (b['percentage'] <=> a['percentage'])
188
- : (a['percentage'] <=> b['percentage'])
189
- pct_cmp == 0 ? (a['file'] <=> b['file']) : pct_cmp
190
- end
191
- end
192
-
193
- private def compute_table_widths(rows)
194
- max_file_length = rows.map { |f| f['file'].length }.max.to_i
195
- file_width = [max_file_length, 'File'.length].max + 2
196
- pct_width = 8
197
- max_covered = rows.map { |f| f['covered'].to_s.length }.max
198
- max_total = rows.map { |f| f['total'].to_s.length }.max
199
- covered_width = [max_covered, 'Covered'.length].max + 2
200
- total_width = [max_total, 'Total'.length].max + 2
201
- stale_width = 'Stale'.length
202
- {
203
- file: file_width,
204
- pct: pct_width,
205
- covered: covered_width,
206
- total: total_width,
207
- stale: stale_width
208
- }
209
- end
210
-
211
- private def border_line(widths, left, middle, right)
212
- h_line = ->(col_width) { '─' * (col_width + 2) }
213
- left +
214
- h_line.call(widths[:file]) +
215
- middle + h_line.call(widths[:pct]) +
216
- middle + h_line.call(widths[:covered]) +
217
- middle + h_line.call(widths[:total]) +
218
- middle + h_line.call(widths[:stale]) +
219
- right
220
- end
221
-
222
- private def header_row(widths)
223
- format(
224
- "│ %-#{widths[:file]}s │ %#{widths[:pct]}s │ %#{widths[:covered]}s │ %#{widths[:total]}s │ %#{widths[:stale]}s │",
225
- 'File', ' %', 'Covered', 'Total', 'Stale'.center(widths[:stale])
226
- )
227
- end
228
-
229
- private def data_row(file_data, widths)
230
- stale_text_str = file_data['stale'] ? file_data['stale'].to_s : ''
231
- format(
232
- "│ %-#{widths[:file]}s │ %#{widths[:pct] - 1}.2f%% │ %#{widths[:covered]}d │ %#{widths[:total]}d │ %#{widths[:stale]}s │",
233
- file_data['file'],
234
- file_data['percentage'],
235
- file_data['covered'],
236
- file_data['total'],
237
- stale_text_str.center(widths[:stale])
238
- )
239
- end
240
-
241
- private def summary_counts(rows)
242
- total = rows.length
243
- stale_count = rows.count { |f| f['stale'] }
244
- ok_count = total - stale_count
245
- "Files: total #{total}, ok #{ok_count}, stale #{stale_count}"
246
- end
247
-
248
- # Filters coverage rows to only include files matching the given glob patterns.
249
- #
250
- # @param rows [Array<Hash>] coverage rows with 'file' keys containing absolute paths
251
- # @param tracked_globs [Array<String>, String, nil] glob patterns to match against
252
- # @return [Array<Hash>] rows whose files match at least one pattern (or all rows if no patterns)
253
- private def filter_rows_by_globs(rows, tracked_globs)
254
- patterns = Array(tracked_globs).compact.map(&:to_s).reject(&:empty?)
255
- return rows if patterns.empty?
256
-
257
- absolute_patterns = patterns.map { |p| absolutize_pattern(p) }
258
- rows.select { |row| matches_any_pattern?(row['file'], absolute_patterns) }
259
- end
260
-
261
- # Converts a relative pattern to absolute by joining with root.
262
- # Absolute patterns are returned unchanged.
263
- #
264
- # @param pattern [String] glob pattern (e.g., "lib/**/*.rb")
265
- # @return [String] absolute pattern
266
- private def absolutize_pattern(pattern)
267
- absolute_pattern?(pattern) ? pattern : File.join(@root, pattern)
268
- end
269
-
270
- # Checks if a pattern is absolute, handling both Unix and Windows-style paths.
271
- # On Unix, Pathname won't recognize "C:/" as absolute, so we check explicitly.
272
- #
273
- # @param pattern [String] glob pattern
274
- # @return [Boolean] true if pattern is absolute
275
- private def absolute_pattern?(pattern)
276
- Pathname.new(pattern).absolute? || pattern.match?(/\A[A-Za-z]:/)
277
- end
278
-
279
- # Tests if a file path matches any of the given absolute glob patterns.
280
- # Uses File.fnmatch? for pure string matching without filesystem access,
281
- # which is faster and works for paths that may no longer exist on disk.
282
- #
283
- # @param abs_path [String] absolute file path to test
284
- # @param patterns [Array<String>] absolute glob patterns
285
- # @return [Boolean] true if the path matches at least one pattern
286
- private def matches_any_pattern?(abs_path, patterns)
287
- flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
288
- patterns.any? { |pattern| File.fnmatch?(pattern, abs_path, flags) }
289
- end
290
-
291
- # Retrieves coverage data for a file path.
292
- # Converts the path to absolute form and performs staleness checking if enabled.
293
- #
294
- # @param path [String] relative or absolute file path
295
- # @return [Array(String, Array)] tuple of [absolute_path, coverage_lines]
296
- # @raise [FileError] if no coverage data exists for the file
297
- # @raise [FileNotFoundError] if the file does not exist
298
- # @raise [CoverageDataStaleError] if staleness checking is enabled and data is stale
299
- private def coverage_data_for(path)
300
- file_abs = File.absolute_path(path, @root)
301
- begin
302
- coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
303
- rescue RuntimeError
304
- raise FileError, "No coverage data found for file: #{path}"
305
- end
306
- @checker.check_file!(file_abs, coverage_lines) unless @checker.off?
307
- if coverage_lines.nil?
308
- raise FileError, "No coverage data found for file: #{path}"
309
- end
310
-
311
- [file_abs, coverage_lines]
312
- rescue Errno::ENOENT
313
- raise FileNotFoundError, "File not found: #{path}"
314
- end
315
-
316
- private def totals_from_rows(rows)
317
- covered = rows.sum { |row| row['covered'].to_i }
318
- total = rows.sum { |row| row['total'].to_i }
319
- uncovered = total - covered
320
- percentage = total.zero? ? 100.0 : ((covered.to_f * 100.0 / total) * 100).round / 100.0
321
- stale_count = rows.count { |row| row['stale'] }
322
- files_total = rows.length
323
-
324
- {
325
- 'lines' => {
326
- 'covered' => covered,
327
- 'uncovered' => uncovered,
328
- 'total' => total
329
- },
330
- 'percentage' => percentage,
331
- 'files' => {
332
- 'total' => files_total,
333
- 'ok' => files_total - stale_count,
334
- 'stale' => stale_count
335
- }
336
- }
337
- end
338
- end
339
- end
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'base_coverage_presenter'
4
-
5
- module CovLoupe
6
- module Presenters
7
- # Provides shared detailed coverage payloads for CLI and MCP callers.
8
- class CoverageDetailedPresenter < BaseCoveragePresenter
9
- private def build_payload
10
- model.detailed_for(path)
11
- end
12
- end
13
- end
14
- end
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'base_coverage_presenter'
4
-
5
- module CovLoupe
6
- module Presenters
7
- # Provides shared raw coverage payloads for CLI and MCP callers.
8
- class CoverageRawPresenter < BaseCoveragePresenter
9
- private def build_payload
10
- model.raw_for(path)
11
- end
12
- end
13
- end
14
- end
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'base_coverage_presenter'
4
-
5
- module CovLoupe
6
- module Presenters
7
- # Builds a consistent summary payload that both the CLI and MCP surfaces can use.
8
- class CoverageSummaryPresenter < BaseCoveragePresenter
9
- private def build_payload
10
- model.summary_for(path)
11
- end
12
- end
13
- end
14
- end
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'base_coverage_presenter'
4
-
5
- module CovLoupe
6
- module Presenters
7
- # Provides shared uncovered coverage payloads for CLI and MCP callers.
8
- class CoverageUncoveredPresenter < BaseCoveragePresenter
9
- private def build_payload
10
- model.uncovered_for(path)
11
- end
12
- end
13
- end
14
- end
@@ -1,131 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
- require 'time'
5
-
6
- require_relative 'errors'
7
- require_relative 'util'
8
-
9
- module CovLoupe
10
- class ResultsetLoader
11
- Result = Struct.new(:coverage_map, :timestamp, :suite_names, keyword_init: true)
12
- SuiteEntry = Struct.new(:name, :coverage, :timestamp, keyword_init: true)
13
-
14
- class << self
15
- def load(resultset_path:)
16
- raw = JSON.load_file(resultset_path)
17
-
18
-
19
- suites = extract_suite_entries(raw, resultset_path)
20
- raise CoverageDataError, "No test suite with coverage data found in resultset file: #{resultset_path}" if suites.empty?
21
-
22
- coverage_map = build_coverage_map(suites, resultset_path)
23
- Result.new(
24
- coverage_map: coverage_map,
25
- timestamp: compute_combined_timestamp(suites),
26
- suite_names: suites.map(&:name)
27
- )
28
- end
29
-
30
- private def extract_suite_entries(raw, resultset_path)
31
- raw
32
- .select { |_, data| data.is_a?(Hash) && data.key?('coverage') && !data['coverage'].nil? }
33
- .map do |name, data|
34
- SuiteEntry.new(
35
- name: name.to_s,
36
- coverage: normalize_suite_coverage(data['coverage'], suite_name: name,
37
- resultset_path: resultset_path),
38
- timestamp: normalize_coverage_timestamp(data['timestamp'], data['created_at'])
39
- )
40
- end
41
- end
42
-
43
- private def build_coverage_map(suites, resultset_path)
44
- return suites.first&.coverage if suites.length == 1
45
-
46
- merge_suite_coverages(suites, resultset_path)
47
- end
48
-
49
- private def normalize_suite_coverage(coverage, suite_name:, resultset_path:)
50
- unless coverage.is_a?(Hash)
51
- raise CoverageDataError, "Invalid coverage data structure for suite #{suite_name.inspect} in resultset file: #{resultset_path}"
52
- end
53
-
54
- needs_adaptation = coverage.values.any? { |value| value.is_a?(Array) }
55
- return coverage unless needs_adaptation
56
-
57
- coverage.transform_values do |value|
58
- value.is_a?(Array) ? { 'lines' => value } : value
59
- end
60
- end
61
-
62
- private def merge_suite_coverages(suites, resultset_path)
63
- require_simplecov_for_merge!(resultset_path)
64
- log_duplicate_suite_names(suites)
65
-
66
- suites.reduce(nil) do |memo, suite|
67
- coverage = suite.coverage
68
- memo ?
69
- SimpleCov::Combine.combine(SimpleCov::Combine::ResultsCombiner, memo, coverage) :
70
- coverage
71
- end
72
- end
73
-
74
- private def require_simplecov_for_merge!(resultset_path)
75
- require 'simplecov'
76
- rescue LoadError
77
- raise CoverageDataError, "Multiple coverage suites detected in #{resultset_path}, but the simplecov gem could not be loaded. Install simplecov to enable suite merging."
78
- end
79
-
80
- private def log_duplicate_suite_names(suites)
81
- grouped = suites.group_by(&:name)
82
- duplicates = grouped.select { |_, entries| entries.length > 1 }.keys
83
- return if duplicates.empty?
84
-
85
- message = "Merging duplicate coverage suites for #{duplicates.join(', ')}"
86
- CovUtil.safe_log(message)
87
- end
88
-
89
- private def compute_combined_timestamp(suites)
90
- suites.map(&:timestamp).compact.max.to_i
91
- end
92
-
93
- private def normalize_coverage_timestamp(timestamp_value, created_at_value)
94
- raw = timestamp_value.nil? ? created_at_value : timestamp_value
95
- return 0 if raw.nil?
96
-
97
- case raw
98
- when Integer
99
- raw
100
- when Float, Time
101
- raw.to_i
102
- when String
103
- normalize_string_timestamp(raw)
104
- else
105
- log_timestamp_warning(raw)
106
- 0
107
- end
108
- rescue => e
109
- log_timestamp_warning(raw, e)
110
- 0
111
- end
112
-
113
- private def normalize_string_timestamp(value)
114
- str = value.strip
115
- return 0 if str.empty?
116
-
117
- if str.match?(/\A-?\d+(\.\d+)?\z/)
118
- str.to_f.to_i
119
- else
120
- Time.parse(str).to_i
121
- end
122
- end
123
-
124
- private def log_timestamp_warning(raw_value, error = nil)
125
- message = "Coverage resultset timestamp could not be parsed: #{raw_value.inspect}"
126
- message = "#{message} (#{error.message})" if error
127
- CovUtil.safe_log(message)
128
- end
129
- end
130
- end
131
- end