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
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module CovLoupe
6
+ # Handles detection and caching of filesystem volume case sensitivity.
7
+ # Provides thread-safe case sensitivity detection with caching for performance.
8
+ module VolumeCaseSensitivity
9
+ # Mutex for thread-safe cache access
10
+ CACHE_MUTEX = Mutex.new
11
+
12
+ class << self
13
+ # Detects whether the volume at the given path is case-sensitive.
14
+ # Prefer using an existing file (via File.identical?) to avoid writing;
15
+ # fall back to a temporary file if no suitable file exists.
16
+ #
17
+ # This method caches results by path to avoid repeated filesystem checks,
18
+ # which can be expensive, especially on network-mounted volumes.
19
+ #
20
+ # @param path [String, nil] directory path to test (defaults to current directory)
21
+ # @return [Boolean] true if case-sensitive, false if case-insensitive or on error
22
+ def volume_case_sensitive?(path = nil)
23
+ require 'securerandom'
24
+
25
+ test_path = path ? File.absolute_path(path) : Dir.pwd
26
+ abs_path = File.absolute_path(test_path)
27
+
28
+ # Check cache first (thread-safe read)
29
+ cached_value = get_from_cache(abs_path)
30
+ return cached_value unless cached_value.nil?
31
+
32
+ # Return false if directory doesn't exist
33
+ return false unless File.directory?(abs_path)
34
+
35
+ result = detect_case_sensitivity?(abs_path)
36
+
37
+ # Store result in cache (thread-safe write)
38
+ set_in_cache(abs_path, result)
39
+
40
+ result
41
+ rescue SystemCallError, IOError
42
+ # Can't detect from filesystem, assume case-insensitive to be conservative
43
+ false
44
+ end
45
+
46
+ # Clears the case sensitivity cache (useful for testing)
47
+ #
48
+ # @return [void]
49
+ def clear_cache
50
+ CACHE_MUTEX.synchronize do
51
+ @cache = {}
52
+ end
53
+ end
54
+
55
+ # Returns the current cache contents (useful for testing)
56
+ #
57
+ # @return [Hash] cache contents
58
+ def cache
59
+ CACHE_MUTEX.synchronize do
60
+ @cache ||= {}
61
+ @cache.dup
62
+ end
63
+ end
64
+
65
+ # Retrieves a value from the cache (thread-safe)
66
+ #
67
+ # @param abs_path [String] absolute path to look up
68
+ # @return [Boolean, nil] cached value or nil if not found
69
+ def get_from_cache(abs_path)
70
+ CACHE_MUTEX.synchronize do
71
+ @cache ||= {}
72
+ @cache[abs_path]
73
+ end
74
+ end
75
+
76
+ # Stores a value in the cache (thread-safe)
77
+ #
78
+ # @param abs_path [String] absolute path to cache
79
+ # @param value [Boolean] value to cache
80
+ # @return [void]
81
+ def set_in_cache(abs_path, value)
82
+ CACHE_MUTEX.synchronize do
83
+ @cache ||= {}
84
+ @cache[abs_path] = value
85
+ end
86
+ end
87
+
88
+ # Detects case sensitivity for a given directory
89
+ #
90
+ # @param abs_path [String] absolute path to directory
91
+ # @return [Boolean] true if case-sensitive, false if case-insensitive
92
+ def detect_case_sensitivity?(abs_path)
93
+ # Try to use an existing file to avoid filesystem writes
94
+ existing_file = find_existing_file(abs_path)
95
+
96
+ if existing_file
97
+ detect_case_sensitive_using_existing_file?(abs_path, existing_file)
98
+ else
99
+ detect_case_sensitive_using_temp_file?(abs_path)
100
+ end
101
+ end
102
+
103
+ # Finds an existing file in the directory suitable for case sensitivity testing
104
+ #
105
+ # @param abs_path [String] absolute path to directory
106
+ # @return [String, nil] filename or nil if no suitable file found
107
+ def find_existing_file(abs_path)
108
+ Dir.children(abs_path).find do |name|
109
+ name.match?(/[A-Za-z]/) && File.file?(File.join(abs_path, name))
110
+ end
111
+ end
112
+
113
+ # Detects case sensitivity using an existing file in the directory
114
+ #
115
+ # @param abs_path [String] absolute path to directory
116
+ # @param existing_file [String] name of existing file
117
+ # @return [Boolean] true if case-sensitive, false if case-insensitive
118
+ def detect_case_sensitive_using_existing_file?(abs_path, existing_file)
119
+ require 'securerandom'
120
+
121
+ original = File.join(abs_path, existing_file)
122
+ alternate_name = existing_file.tr('A-Za-z', 'a-zA-Z')
123
+ alternate = File.join(abs_path, alternate_name)
124
+
125
+ if File.exist?(alternate)
126
+ # Same file -> case-insensitive, different files -> case-sensitive
127
+ !File.identical?(original, alternate)
128
+ else
129
+ true
130
+ end
131
+ end
132
+
133
+ # Detects case sensitivity using a temporary test file
134
+ #
135
+ # @param abs_path [String] absolute path to directory
136
+ # @return [Boolean] true if case-sensitive, false if case-insensitive
137
+ def detect_case_sensitive_using_temp_file?(abs_path)
138
+ require 'securerandom'
139
+
140
+ # Create a temporary test file with a unique name
141
+ test_file = generate_unique_test_filename(abs_path)
142
+
143
+ begin
144
+ FileUtils.touch(test_file)
145
+ variants = [test_file, test_file.upcase, test_file.downcase]
146
+ # Test if exactly one variant exists (case-sensitive) vs all exist (case-insensitive)
147
+ variants.one? { |variant| File.exist?(variant) }
148
+ ensure
149
+ # Clean up all potential variants
150
+ [test_file, test_file.upcase, test_file.downcase].each do |variant|
151
+ FileUtils.rm_f(variant)
152
+ end
153
+ end
154
+ end
155
+
156
+ # Generates a unique test filename that doesn't conflict with existing files
157
+ #
158
+ # @param abs_path [String] absolute path to directory
159
+ # @return [String] unique filename path
160
+ def generate_unique_test_filename(abs_path)
161
+ require 'securerandom'
162
+
163
+ test_file = nil
164
+ while test_file.nil?
165
+ candidate = File.join(abs_path, "CovLoupe_CaseSensitivity_Test_#{SecureRandom.hex(16)}.tmp")
166
+ variants = [candidate, candidate.upcase, candidate.downcase]
167
+ test_file = candidate if variants.none? { |v| File.exist?(v) }
168
+ end
169
+ test_file
170
+ end
171
+ end
172
+ end
173
+ end
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'payload_caching'
4
+
3
5
  module CovLoupe
4
6
  module Presenters
5
7
  # Shared presenter behavior for single-file coverage payloads.
6
8
  class BaseCoveragePresenter
9
+ include PayloadCaching
10
+
7
11
  attr_reader :model, :path
8
12
 
9
13
  def initialize(model:, path:)
@@ -11,19 +15,6 @@ module CovLoupe
11
15
  @path = path
12
16
  end
13
17
 
14
- # Returns the absolute-path payload augmented with stale metadata.
15
- def absolute_payload
16
- @absolute_payload ||= begin
17
- payload = build_payload
18
- payload.merge('stale' => model.staleness_for(path))
19
- end
20
- end
21
-
22
- # Returns the payload with file paths relativized for presentation.
23
- def relativized_payload
24
- @relativized_payload ||= model.relativize(absolute_payload)
25
- end
26
-
27
18
  # Returns the cached stale status for the file.
28
19
  def stale
29
20
  absolute_payload['stale']
@@ -34,6 +25,11 @@ module CovLoupe
34
25
  relativized_payload['file']
35
26
  end
36
27
 
28
+ private def compute_absolute_payload
29
+ payload = build_payload
30
+ payload.merge('stale' => model.staleness_for(path))
31
+ end
32
+
37
33
  private def build_payload
38
34
  raise NotImplementedError, "#{self.class} must implement #build_payload"
39
35
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_coverage_presenter'
4
+
5
+ module CovLoupe
6
+ module Presenters
7
+ # Provides shared single-file coverage payloads for CLI and MCP callers.
8
+ class CoveragePayloadPresenter < BaseCoveragePresenter
9
+ def initialize(model:, path:, payload_method:, raise_on_stale: nil)
10
+ super(model: model, path: path)
11
+ @payload_method = payload_method
12
+ @raise_on_stale = raise_on_stale
13
+ end
14
+
15
+ private def build_payload
16
+ args = { raise_on_stale: @raise_on_stale }.compact
17
+ model.public_send(@payload_method, path, **args)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CovLoupe
4
+ module Presenters
5
+ # Shared memoization logic for coverage payloads.
6
+ module PayloadCaching
7
+ # Returns the absolute-path payload.
8
+ # Consumers must implement #compute_absolute_payload.
9
+ def absolute_payload
10
+ @absolute_payload ||= compute_absolute_payload
11
+ end
12
+
13
+ # Returns the payload with file paths relativized for presentation.
14
+ def relativized_payload
15
+ @relativized_payload ||= model.relativize(absolute_payload)
16
+ end
17
+
18
+ private def compute_absolute_payload
19
+ raise NotImplementedError, "#{self.class} must implement #compute_absolute_payload"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,35 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'payload_caching'
4
+
5
+ require_relative '../staleness/stale_status'
6
+
3
7
  module CovLoupe
4
8
  module Presenters
5
9
  # Provides repository-wide coverage summaries shared by CLI and MCP surfaces.
6
10
  class ProjectCoveragePresenter
7
- attr_reader :model, :sort_order, :check_stale, :tracked_globs
11
+ include PayloadCaching
12
+
13
+ attr_reader :model, :sort_order, :raise_on_stale, :tracked_globs
8
14
 
9
- def initialize(model:, sort_order:, check_stale:, tracked_globs:)
15
+ def initialize(model:, sort_order:, raise_on_stale:, tracked_globs:)
10
16
  @model = model
11
17
  @sort_order = sort_order
12
- @check_stale = check_stale
18
+ @raise_on_stale = raise_on_stale
13
19
  @tracked_globs = tracked_globs
14
20
  end
15
21
 
16
- # Returns the absolute-path payload including counts.
17
- def absolute_payload
18
- @absolute_payload ||= begin
19
- files = model.all_files(
20
- sort_order: sort_order,
21
- check_stale: check_stale,
22
- tracked_globs: tracked_globs
23
- )
24
- { 'files' => files, 'counts' => build_counts(files) }
25
- end
26
- end
27
-
28
- # Returns the payload with file paths relativized for presentation.
29
- def relativized_payload
30
- @relativized_payload ||= model.relativize(absolute_payload)
31
- end
32
-
33
22
  # Returns the relativized file rows.
34
23
  def relative_files
35
24
  relativized_payload['files']
@@ -40,9 +29,72 @@ module CovLoupe
40
29
  relativized_payload['counts']
41
30
  end
42
31
 
32
+ # Returns the relativized skipped files.
33
+ def relative_skipped_files
34
+ relativized_payload['skipped_files']
35
+ end
36
+
37
+ # Returns the relativized missing tracked files.
38
+ def relative_missing_tracked_files
39
+ relativized_payload['missing_tracked_files']
40
+ end
41
+
42
+ # Returns the relativized newer files.
43
+ def relative_newer_files
44
+ relativized_payload['newer_files']
45
+ end
46
+
47
+ # Returns the relativized deleted files.
48
+ def relative_deleted_files
49
+ relativized_payload['deleted_files']
50
+ end
51
+
52
+ # Returns the relativized length-mismatch files.
53
+ def relative_length_mismatch_files
54
+ relativized_payload['length_mismatch_files']
55
+ end
56
+
57
+ # Returns the relativized unreadable files.
58
+ def relative_unreadable_files
59
+ relativized_payload['unreadable_files']
60
+ end
61
+
62
+ # Returns the timestamp status indicating whether coverage timestamps are available.
63
+ # Can be 'ok' (timestamps available) or 'missing' (no timestamps, staleness checks skipped).
64
+ def timestamp_status
65
+ relativized_payload['timestamp_status']
66
+ end
67
+
68
+ private def compute_absolute_payload
69
+ list_result = model.list(
70
+ sort_order: sort_order,
71
+ raise_on_stale: raise_on_stale,
72
+ tracked_globs: tracked_globs
73
+ )
74
+ files = list_result['files']
75
+ skipped_files = list_result['skipped_files']
76
+ missing_tracked_files = list_result['missing_tracked_files']
77
+ newer_files = list_result['newer_files']
78
+ deleted_files = list_result['deleted_files']
79
+ length_mismatch_files = list_result['length_mismatch_files']
80
+ unreadable_files = list_result['unreadable_files']
81
+ timestamp_status = list_result['timestamp_status']
82
+ {
83
+ 'files' => files,
84
+ 'skipped_files' => skipped_files,
85
+ 'missing_tracked_files' => missing_tracked_files,
86
+ 'newer_files' => newer_files,
87
+ 'deleted_files' => deleted_files,
88
+ 'length_mismatch_files' => length_mismatch_files,
89
+ 'unreadable_files' => unreadable_files,
90
+ 'timestamp_status' => timestamp_status,
91
+ 'counts' => build_counts(files)
92
+ }
93
+ end
94
+
43
95
  private def build_counts(files)
44
96
  total = files.length
45
- stale = files.count { |f| f['stale'] }
97
+ stale = files.count { |f| StaleStatus.stale?(f['stale']) }
46
98
  { 'total' => total, 'ok' => total - stale, 'stale' => stale }
47
99
  end
48
100
  end
@@ -1,26 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'payload_caching'
4
+
3
5
  module CovLoupe
4
6
  module Presenters
5
7
  # Provides aggregated line totals and average coverage across the project.
6
8
  class ProjectTotalsPresenter
7
- attr_reader :model, :check_stale, :tracked_globs
9
+ include PayloadCaching
10
+
11
+ attr_reader :model, :raise_on_stale, :tracked_globs
8
12
 
9
- def initialize(model:, check_stale:, tracked_globs:)
13
+ def initialize(model:, raise_on_stale:, tracked_globs:)
10
14
  @model = model
11
- @check_stale = check_stale
15
+ @raise_on_stale = raise_on_stale
12
16
  @tracked_globs = tracked_globs
13
17
  end
14
18
 
15
- def absolute_payload
16
- @absolute_payload ||= model.project_totals(
17
- tracked_globs: tracked_globs,
18
- check_stale: check_stale
19
- )
19
+ # Returns the timestamp status indicating whether coverage timestamps are available.
20
+ # Can be 'ok' (timestamps available) or 'missing' (no timestamps, staleness checks skipped).
21
+ def timestamp_status
22
+ relativized_payload['timestamp_status']
20
23
  end
21
24
 
22
- def relativized_payload
23
- @relativized_payload ||= model.relativize(absolute_payload)
25
+ private def compute_absolute_payload
26
+ model.project_totals(
27
+ tracked_globs: tracked_globs,
28
+ raise_on_stale: raise_on_stale
29
+ )
24
30
  end
25
31
  end
26
32
  end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../resolvers/resolver_helpers'
5
+ require_relative '../loaders/resultset_loader'
6
+ require_relative '../errors/errors'
7
+ require_relative '../paths/path_utils'
8
+
9
+ module CovLoupe
10
+ module Repositories
11
+ # CoverageRepository handles the discovery, loading, and normalization of SimpleCov
12
+ # coverage data. It decouples data access concerns from the domain logic in CoverageModel.
13
+ #
14
+ # Its primary responsibilities are:
15
+ # 1. Locating the .resultset.json file using ResolverHelpers.
16
+ # 2. Loading and parsing the JSON data using ResultsetLoader (handling suite merging if needed).
17
+ # 3. Normalizing all coverage map keys to absolute paths relative to the project root.
18
+ #
19
+ # @attr_reader coverage_map [Hash] A map of absolute file paths to coverage data.
20
+ # @attr_reader timestamp [Integer] The latest timestamp from the loaded coverage suites.
21
+ # @attr_reader resultset_path [String] The resolved absolute path to the .resultset.json file.
22
+ class CoverageRepository
23
+ attr_reader :coverage_map, :timestamp, :resultset_path
24
+
25
+ def initialize(root:, resultset_path: nil, logger: nil)
26
+ @root = root
27
+ @logger = logger || CovLoupe.logger
28
+
29
+ begin
30
+ # 1. Locate the file
31
+ @resultset_path = resolve_resultset_path(resultset_path)
32
+
33
+ # 2. Load the data
34
+ loaded_data = load_data
35
+
36
+ # 3. Detect volume case sensitivity from project root
37
+ @volume_case_sensitive = detect_volume_case_sensitivity
38
+
39
+ # 4. Normalize keys to absolute paths
40
+ @coverage_map = normalize_paths(loaded_data.coverage_map)
41
+ @timestamp = loaded_data.timestamp
42
+ rescue CovLoupe::Error
43
+ raise # Re-raise our own errors as-is
44
+ rescue => e
45
+ raise ErrorHandler.new.convert_standard_error(e, context: :coverage_loading)
46
+ end
47
+ end
48
+
49
+ private def resolve_resultset_path(path_arg)
50
+ Resolvers::ResolverHelpers.find_resultset(@root, resultset: path_arg)
51
+ end
52
+
53
+ private def load_data
54
+ ResultsetLoader.load(resultset_path: @resultset_path, logger: @logger)
55
+ end
56
+
57
+ # Detects volume case sensitivity from the project root directory.
58
+ # Uses @root because coverage map keys are paths to source files in the project.
59
+ #
60
+ # Falls back to assuming case-insensitive if @root doesn't exist (test scenarios)
61
+ # or isn't accessible. This conservative fallback catches more potential collisions.
62
+ #
63
+ # @return [Boolean] true if volume is case-sensitive
64
+ private def detect_volume_case_sensitivity
65
+ return false unless File.directory?(@root)
66
+
67
+ PathUtils.volume_case_sensitive?(@root)
68
+ rescue SystemCallError, IOError
69
+ # Can't detect from filesystem, assume case-insensitive to be conservative
70
+ false
71
+ end
72
+
73
+ # Normalizes all coverage map keys to absolute paths and detects collisions.
74
+ #
75
+ # This method transforms relative and mixed-case paths to their canonical absolute
76
+ # form. If multiple original keys normalize to the same path (e.g., "lib/foo.rb" and
77
+ # "/full/path/lib/foo.rb"), this indicates corrupt or problematic coverage data that
78
+ # would otherwise silently overwrite earlier entries.
79
+ #
80
+ # On case-insensitive volumes, paths that differ only in case (e.g., "Foo.rb" and
81
+ # "foo.rb") are detected as collisions. The original case is preserved in stored keys
82
+ # for correct display in error messages and reports.
83
+ #
84
+ # @param map [Hash] Original coverage map with potentially relative/mixed keys
85
+ # @return [Hash] Normalized coverage map with absolute path keys (preserving original case)
86
+ # @raise [CoverageDataError] If duplicate keys normalize to the same path
87
+ private def normalize_paths(map)
88
+ return {} unless map
89
+
90
+ result = {}
91
+ # Track which original keys map to each normalized key to detect collisions
92
+ # Example: { "/abs/path/lib/foo.rb" => ["lib/foo.rb", "/abs/path/lib/foo.rb"] }
93
+ provided_paths_by_normalized_path = Hash.new { |h, k| h[k] = [] }
94
+ # Track the expanded (but not case-normalized) key for storage
95
+ # Example: { "/abs/path/lib/foo.rb" => "/full/path/lib/foo.rb" }
96
+ expanded_by_normalized = {}
97
+
98
+ # First pass: normalize all keys and track the mapping
99
+ map.each do |original_key, value|
100
+ # Expand to absolute path first
101
+ expanded_key = PathUtils.expand(original_key, @root)
102
+
103
+ # Then apply case normalization for collision detection only
104
+ # Pass root to ensure case-sensitivity is derived from root's volume
105
+ normalized_key = PathUtils.normalize(
106
+ expanded_key,
107
+ normalize_case: !@volume_case_sensitive,
108
+ root: @root
109
+ )
110
+
111
+ provided_paths_by_normalized_path[normalized_key] << original_key
112
+ # Store using expanded key (preserves original case) for display purposes
113
+ expanded_by_normalized[normalized_key] ||= expanded_key
114
+ result[expanded_by_normalized[normalized_key]] = value
115
+ end
116
+
117
+ # Second pass: detect collisions (any normalized key with multiple original keys)
118
+ collisions = provided_paths_by_normalized_path.select do |_norm_key, orig_keys|
119
+ orig_keys.size > 1
120
+ end
121
+
122
+ collisions.empty? ? result : raise_collision_error(collisions, expanded_by_normalized)
123
+ end
124
+
125
+ # Raises a CoverageDataError with details about path normalization collisions.
126
+ #
127
+ # Formats collision data as parseable JSON with each collision on one line:
128
+ # {
129
+ # "/full/path/lib/foo.rb": ["lib/foo.rb", "/full/path/lib/foo.rb"],
130
+ # "/full/path/lib/bar.rb": ["lib/bar.rb", "/full/path/lib/bar.rb"]
131
+ # }
132
+ #
133
+ # @param collisions [Hash] Map of normalized paths to arrays of original keys
134
+ # @param expanded_by_normalized [Hash] Map of normalized paths to case-preserved expanded paths
135
+ # @raise [CoverageDataError] Always raises with formatted collision details
136
+ private def raise_collision_error(collisions, expanded_by_normalized)
137
+ json_lines = collisions.map do |norm_key, orig_keys|
138
+ # Use the case-preserved expanded key instead of the normalized key
139
+ expanded_key = expanded_by_normalized[norm_key]
140
+ " #{JSON.generate(expanded_key)}: #{JSON.generate(orig_keys)}"
141
+ end
142
+ details = "{\n#{json_lines.join(",\n")}\n}"
143
+
144
+ raise CoverageDataError,
145
+ "Duplicate paths detected after normalization. The following keys normalize to the same path:\n#{details}"
146
+ end
147
+ end
148
+ end
149
+ end