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,19 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../paths/path_utils'
4
+
3
5
  module CovLoupe
4
6
  module Resolvers
7
+ # Finds a SimpleCov line coverage array for a given file path.
8
+ #
9
+ # This is a string-based resolver: it does not touch the filesystem. It
10
+ # looks up keys in the coverage map using two strategies:
11
+ # 1) exact match on the provided path
12
+ # 2) match after stripping the configured root prefix
5
13
  class CoverageLineResolver
6
- def initialize(cov_data)
14
+ # @param cov_data [Hash] coverage data map keyed by file path
15
+ # @param root [String, nil] project root used for path stripping
16
+ # @param volume_case_sensitive [Boolean] whether the volume is case-sensitive
17
+ def initialize(cov_data, root:, volume_case_sensitive:)
7
18
  @cov_data = cov_data
19
+ @root = root
20
+ @normalize_case = !volume_case_sensitive
8
21
  end
9
22
 
23
+ # Resolve coverage lines for a file path, trying fallbacks before raising.
24
+ # @param file_abs [String] absolute file path to resolve
25
+ # @return [Array<Integer, nil>] SimpleCov-style line coverage array
10
26
  def lookup_lines(file_abs)
27
+ # Normalize the input path first to handle platform-specific differences
28
+ normalized_path = normalize_path(file_abs)
29
+
11
30
  # First try exact match
12
- direct_match = find_direct_match(file_abs)
31
+ direct_match = find_direct_match(normalized_path)
13
32
  return direct_match if direct_match
14
33
 
15
34
  # Then try without current working directory prefix
16
- stripped_match = find_stripped_match(file_abs)
35
+ stripped_match = find_stripped_match(normalized_path)
17
36
  return stripped_match if stripped_match
18
37
 
19
38
  raise_not_found_error(file_abs)
@@ -22,100 +41,95 @@ module CovLoupe
22
41
  attr_reader :cov_data
23
42
 
24
43
  private def find_direct_match(file_abs)
25
- entry = cov_data[file_abs]
26
- lines_from_entry(entry)
44
+ fetch_lines_for_path(file_abs)
27
45
  end
28
46
 
47
+ # Try matching a path after removing the root prefix.
29
48
  private def find_stripped_match(file_abs)
30
- return unless file_abs.start_with?(cwd_with_slash)
49
+ return unless @root
50
+
51
+ normalized_file = normalize_path(file_abs)
52
+ return unless normalized_file.start_with?(normalized_root_with_slash)
53
+
54
+ relative_path = normalized_file[(normalized_root.length + 1)..]
55
+ fetch_lines_for_path(relative_path)
56
+ end
57
+
58
+ # Fetch lines for a path, resolving normalized separators when needed.
59
+ private def fetch_lines_for_path(path)
60
+ key = resolve_key(path)
61
+ return unless key
62
+
63
+ entry = cov_data[key]
64
+ lines = lines_from_entry(entry)
65
+ return lines if lines
66
+
67
+ raise CorruptCoverageDataError, "Entry for #{path} has no valid lines"
68
+ end
69
+
70
+ private def resolution_root
71
+ @resolution_root ||= @root
72
+ end
73
+
74
+ private def normalized_root
75
+ @normalized_root ||= normalize_path(resolution_root)
76
+ end
31
77
 
32
- relative_path = file_abs[(cwd.length + 1)..]
33
- entry = cov_data[relative_path]
34
- lines_from_entry(entry)
78
+ private def normalized_root_with_slash
79
+ return unless normalized_root
80
+
81
+ @normalized_root_with_slash ||= "#{normalized_root}/"
35
82
  end
36
83
 
37
- private def cwd
38
- @cwd ||= Dir.pwd
84
+ # Resolve the coverage key that matches a path (including normalized variants).
85
+ private def resolve_key(path)
86
+ normalized = normalize_path(path)
87
+ match_keys = cov_data.keys.select { |key| normalize_path(key) == normalized }
88
+
89
+ return if match_keys.empty?
90
+
91
+ # If exact path match exists and it's the only one, return it
92
+ return path if cov_data.key?(path) && match_keys.length == 1
93
+
94
+ # If multiple matches, raise ambiguity error
95
+ if match_keys.length > 1
96
+ raise FileError, "Multiple coverage entries match path #{path}: #{match_keys.join(', ')}"
97
+ end
98
+
99
+ # Single match found, return it
100
+ match_keys.first
39
101
  end
40
102
 
41
- private def cwd_with_slash
42
- @cwd_with_slash ||= "#{cwd}/"
103
+ # Normalize a path using centralized PathUtils
104
+ private def normalize_path(path)
105
+ PathUtils.normalize(path, normalize_case: @normalize_case)
43
106
  end
44
107
 
45
108
  private def raise_not_found_error(file_abs)
46
- raise FileError, "No coverage entry found for #{file_abs}"
109
+ raise FileError, "No coverage entry found for #{normalize_path(file_abs)}"
47
110
  end
48
111
 
49
- # Entry may store exact line coverage, branch-only coverage, or neither.
50
- # Prefer the provided `lines` array but fall back to synthesizing one so
51
- # callers always receive something enumerable.
112
+ # Entry may store exact line coverage.
113
+ #
114
+ # Validates that the entry contains a properly-formed lines array
115
+ # with only Integer or nil elements.
52
116
  #
53
- # Returning nil tells callers to keep searching; the resolver will raise
54
- # a FileError if no variant yields coverage data.
117
+ # @raise [CoverageDataError] if lines array contains invalid elements
118
+ # @return [Array<Integer, nil>, nil] validated lines array or nil if entry lacks lines
55
119
  private def lines_from_entry(entry)
56
120
  return unless entry.is_a?(Hash)
57
121
 
58
122
  lines = entry['lines']
59
- return lines if lines.is_a?(Array)
123
+ return nil unless lines.is_a?(Array)
60
124
 
61
- synthesize_lines_from_branches(entry['branches'])
62
- end
63
-
64
- # Some SimpleCov configurations track only branch coverage. When the
65
- # resultset omits the legacy `lines` array we rebuild a minimal substitute
66
- # so the rest of the pipeline (summaries, uncovered lines, staleness) can
67
- # continue to operate.
68
- #
69
- # Branch data looks like:
70
- # "[:if, 0, 12, 4, 20, 29]" => { "[:then, ...]" => hits, ... }
71
- # We care about the third tuple element (line number). We sum branch-leg
72
- # hits per line so the synthetic array still behaves like legacy line
73
- # coverage (any positive value counts as executed).
74
- private def synthesize_lines_from_branches(branch_data)
75
- # Detailed shape and rationale documented in docs/BRANCH_ONLY_COVERAGE.md
76
- return unless branch_data.is_a?(Hash) && branch_data.any?
77
-
78
- line_hits = {}
79
-
80
- branch_data
81
- .values
82
- .select { |targets| targets.is_a?(Hash) } # ignore malformed branch entries
83
- .flat_map(&:to_a) # flatten each branch target into [meta, hits]
84
- .filter_map do |meta, hits|
85
- # Extract the covered line; filter_map discards nil results.
86
- line_number = extract_line_number(meta)
87
- line_number && [line_number, hits.to_i]
88
- end
89
- .each do |line_number, hits|
90
- line_hits[line_number] = line_hits.fetch(line_number, 0) + hits
91
- end
92
-
93
- return if line_hits.empty?
94
-
95
- max_line = line_hits.keys.max
96
- # Build a dense array up to the highest line recorded so downstream
97
- # consumers see the familiar SimpleCov shape (nil for untouched lines).
98
- Array.new(max_line) { |idx| line_hits[idx + 1] }
99
- end
100
-
101
- # Branch metadata arrives as either the raw SimpleCov array
102
- # (e.g. [:if, 0, 12, 4, 20, 29]) or the stringified JSON version
103
- # ("[:if, 0, 12, 4, 20, 29]"). We normalize both forms and pull the line.
104
- private def extract_line_number(meta)
105
- if meta.is_a?(Array)
106
- line_token = meta[2]
107
- # Integer(..., exception: false) returns nil on failure, so malformed
108
- # tuples quietly drop out of the synthesized array.
109
- return Integer(line_token, exception: false)
125
+ # Validate all elements are Integer or nil
126
+ invalid_elements = lines.reject { |v| v.nil? || v.is_a?(Integer) }
127
+ unless invalid_elements.empty?
128
+ raise CoverageDataError,
129
+ "Invalid coverage line array: contains non-integer elements: #{invalid_elements.inspect}"
110
130
  end
111
131
 
112
- tokens = meta.to_s.tr('[]', '').split(',').map(&:strip)
113
- return if tokens.length < 3
114
-
115
- Integer(tokens[2], exception: false)
116
- # Any parsing errors result in nil; callers treat that as "no line".
117
- rescue ArgumentError, TypeError
118
- nil
132
+ lines
119
133
  end
120
134
  end
121
135
  end
@@ -5,23 +5,24 @@ require_relative 'coverage_line_resolver'
5
5
 
6
6
  module CovLoupe
7
7
  module Resolvers
8
- class ResolverFactory
8
+ class ResolverHelpers
9
9
  def self.create_resultset_resolver(root: Dir.pwd, resultset: nil, candidates: nil)
10
10
  candidates ?
11
11
  ResultsetPathResolver.new(root: root, candidates: candidates) :
12
12
  ResultsetPathResolver.new(root: root)
13
13
  end
14
14
 
15
- def self.create_coverage_resolver(cov_data)
16
- CoverageLineResolver.new(cov_data)
15
+ def self.create_coverage_resolver(cov_data, root:, volume_case_sensitive:)
16
+ CoverageLineResolver.new(cov_data, root: root, volume_case_sensitive: volume_case_sensitive)
17
17
  end
18
18
 
19
19
  def self.find_resultset(root, resultset: nil)
20
20
  ResultsetPathResolver.new(root: root).find_resultset(resultset: resultset)
21
21
  end
22
22
 
23
- def self.lookup_lines(cov, file_abs)
24
- CoverageLineResolver.new(cov).lookup_lines(file_abs)
23
+ def self.lookup_lines(cov, file_abs, root:, volume_case_sensitive:)
24
+ CoverageLineResolver.new(cov, root: root,
25
+ volume_case_sensitive: volume_case_sensitive).lookup_lines(file_abs)
25
26
  end
26
27
  end
27
28
  end
@@ -2,6 +2,9 @@
2
2
 
3
3
  require 'pathname'
4
4
 
5
+ require_relative '../errors/errors'
6
+ require_relative '../paths/path_utils'
7
+
5
8
  module CovLoupe
6
9
  module Resolvers
7
10
  class ResultsetPathResolver
@@ -39,37 +42,62 @@ module CovLoupe
39
42
  candidate = File.join(path, '.resultset.json')
40
43
  return candidate if File.file?(candidate)
41
44
 
42
- raise "No .resultset.json found in directory: #{path}"
45
+ raise ResultsetNotFoundError, "No .resultset.json found in directory: #{path}"
43
46
  end
44
47
 
45
48
  private def raise_not_found_error_for_file(path)
46
- raise "Specified resultset not found: #{path}"
49
+ raise ResultsetNotFoundError, "Specified resultset not found: #{path}"
47
50
  end
48
51
 
49
52
  private def resolve_fallback
50
53
  @candidates
51
- .map { |p| File.absolute_path(p, @root) }
54
+ .map { |p| PathUtils.expand(p, @root) }
52
55
  .find { |p| File.file?(p) }
53
56
  end
54
57
 
55
58
  private def normalize_resultset_path(resultset)
56
- candidate = Pathname.new(resultset)
57
- return candidate.cleanpath.to_s if candidate.absolute?
59
+ Pathname.new(resultset)
60
+ expanded_resultset = PathUtils.expand(resultset, Dir.pwd)
61
+ expanded_root = PathUtils.expand(resultset, @root)
62
+
63
+ if ambiguous_resultset_path?(expanded_resultset, expanded_root)
64
+ raise_ambiguous_resultset_error(expanded_resultset, expanded_root)
65
+ end
58
66
 
59
- expanded = File.expand_path(resultset, Dir.pwd)
60
- return expanded if within_root?(expanded)
67
+ return expanded_resultset if valid_resultset_location?(expanded_resultset)
68
+ return expanded_root if valid_resultset_location?(expanded_root)
61
69
 
62
- File.absolute_path(resultset, @root)
70
+ return expanded_resultset if within_root?(expanded_resultset)
71
+
72
+ expanded_root
63
73
  end
64
74
 
65
75
  private def within_root?(path)
66
- normalized_root = Pathname.new(@root).cleanpath.to_s
67
- root_with_sep = normalized_root.end_with?(File::SEPARATOR) ? normalized_root : "#{normalized_root}#{File::SEPARATOR}"
68
- path == normalized_root || path.start_with?(root_with_sep)
76
+ PathUtils.within_root?(path, @root)
77
+ end
78
+
79
+ private def ambiguous_resultset_path?(expanded_pwd, expanded_root)
80
+ return false if expanded_pwd == expanded_root
81
+
82
+ valid_resultset_location?(expanded_pwd) && valid_resultset_location?(expanded_root)
83
+ end
84
+
85
+ private def valid_resultset_location?(path)
86
+ return true if File.file?(path)
87
+ return false unless File.directory?(path)
88
+
89
+ File.file?(File.join(path, '.resultset.json'))
90
+ end
91
+
92
+ private def raise_ambiguous_resultset_error(expanded_pwd, expanded_root)
93
+ raise ConfigurationError, "Ambiguous resultset location specified. Both #{expanded_pwd} and #{expanded_root} exist. " \
94
+ 'Use `./` or an absolute filespec to disambiguate.'
69
95
  end
70
96
 
71
97
  private def raise_not_found_error
72
- raise "Could not find .resultset.json under #{@root.inspect}; run tests or set --resultset option"
98
+ message = "Could not find .resultset.json under #{@root.inspect}; run tests or set --resultset option"
99
+ CovLoupe.logger.error(message) if CovLoupe.logger
100
+ raise ResultsetNotFoundError, message
73
101
  end
74
102
  end
75
103
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'shellwords'
5
+
6
+ module CovLoupe
7
+ module Scripts
8
+ module CommandExecution
9
+ # Execute a command and return its stdout.
10
+ #
11
+ # @param cmd [String, Array<String>] The shell command to run.
12
+ # @param print_output [Boolean] If true, prints output to stdout/stderr in real-time.
13
+ # @param fail_on_error [Boolean] If true, aborts execution if the command fails.
14
+ # @return [String] The stdout output of the command (stripped).
15
+ def run_command(cmd, print_output: false, fail_on_error: true)
16
+ if print_output
17
+ run_streamed(cmd, fail_on_error: fail_on_error)
18
+ else
19
+ run_captured(cmd, fail_on_error: fail_on_error)
20
+ end
21
+ end
22
+
23
+ # Execute a command and return stdout and success status.
24
+ #
25
+ # @param cmd [String, Array<String>] The shell command to run.
26
+ # @return [Array<String, Boolean>] The stdout and success boolean.
27
+ def run_command_with_status(cmd)
28
+ stdout, _stderr, status = capture_command(cmd)
29
+ [stdout.strip, status.success?]
30
+ rescue Errno::ENOENT
31
+ ["Command not found: #{command_display(cmd)}", false]
32
+ end
33
+
34
+ # Print an error message and exit with status 1.
35
+ def abort_with(message)
36
+ warn "ERROR: #{message}"
37
+ exit 1
38
+ end
39
+
40
+ # Check if a command exists in the system PATH.
41
+ def command_exists?(cmd)
42
+ return true if File.exist?(cmd) && File.executable?(cmd)
43
+
44
+ checker = Gem.win_platform? ? 'where' : 'which'
45
+ system(checker, cmd, out: File::NULL, err: File::NULL)
46
+ end
47
+
48
+ private def run_streamed(cmd, fail_on_error:)
49
+ puts "→ #{command_display(cmd)}"
50
+ output = +''
51
+ status = nil
52
+
53
+ begin
54
+ popen_command(cmd) do |_stdin, stdout_err, wait_thr|
55
+ stdout_err.each do |line|
56
+ print line
57
+ output << line
58
+ end
59
+ status = wait_thr.value
60
+ end
61
+ rescue Errno::ENOENT
62
+ abort_with("Command not found: #{command_display(cmd)}") if fail_on_error
63
+ return ''
64
+ end
65
+
66
+ if fail_on_error && !status&.success?
67
+ abort_with("Command failed: #{cmd}")
68
+ end
69
+
70
+ output.strip
71
+ end
72
+
73
+ private def run_captured(cmd, fail_on_error:)
74
+ begin
75
+ stdout, stderr, status = capture_command(cmd)
76
+ rescue Errno::ENOENT
77
+ abort_with("Command not found: #{command_display(cmd)}") if fail_on_error
78
+ return ''
79
+ end
80
+
81
+ if fail_on_error && !status.success?
82
+ warn "Error running: #{command_display(cmd)}"
83
+ warn stderr unless stderr.strip.empty?
84
+ exit 1
85
+ end
86
+
87
+ stdout.strip
88
+ end
89
+
90
+ private def popen_command(cmd, &)
91
+ if cmd.is_a?(Array)
92
+ Open3.popen2e(*cmd, &)
93
+ else
94
+ Open3.popen2e(cmd, &)
95
+ end
96
+ end
97
+
98
+ private def capture_command(cmd)
99
+ if cmd.is_a?(Array)
100
+ Open3.capture3(*cmd)
101
+ else
102
+ Open3.capture3(cmd)
103
+ end
104
+ end
105
+
106
+ private def command_display(cmd)
107
+ return Shellwords.join(cmd) if cmd.is_a?(Array)
108
+
109
+ cmd.to_s
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'command_execution'
5
+
6
+ module CovLoupe
7
+ module Scripts
8
+ class LatestCiStatus
9
+ include CommandExecution
10
+
11
+ def call
12
+ branch = fetch_current_branch
13
+ puts "Fetching latest CI run for branch: #{branch}..."
14
+
15
+ run_data = fetch_latest_run(branch)
16
+
17
+ if run_data.nil?
18
+ puts "No workflow runs found for branch '#{branch}'."
19
+ return
20
+ end
21
+
22
+ display_run_details(run_data)
23
+ end
24
+
25
+ private def fetch_current_branch
26
+ run_command(%w[git rev-parse --abbrev-ref HEAD])
27
+ end
28
+
29
+ private def fetch_latest_run(branch)
30
+ json_output, success = run_command_with_status(
31
+ ['gh', 'run', 'list', '--branch', branch, '--limit', '1', '--json',
32
+ 'databaseId,status,conclusion,url,displayTitle,createdAt']
33
+ )
34
+
35
+ unless success
36
+ warn "Failed to fetch runs. Ensure 'gh' is installed and you are authenticated."
37
+ exit 1
38
+ end
39
+
40
+ runs = JSON.parse(json_output)
41
+ runs.first
42
+ end
43
+
44
+ private def display_run_details(run)
45
+ id = run['databaseId']
46
+ status = run['status']
47
+ conclusion = run['conclusion']
48
+ url = run['url']
49
+ title = run['displayTitle']
50
+ created_at = run['createdAt']
51
+
52
+ color = status_color(status, conclusion)
53
+ display_status = status == 'completed' ? (conclusion || 'unknown').upcase : status.upcase
54
+
55
+ puts "\nLatest Run Details:"
56
+ puts '-------------------'
57
+ puts "Title: #{title}"
58
+ puts "ID: #{id}"
59
+ puts "Time: #{created_at}"
60
+ puts "Status: #{colorize(display_status, color)}"
61
+ puts "URL: #{url}"
62
+
63
+ handle_status_action(status, conclusion, id)
64
+ end
65
+
66
+ private def handle_status_action(status, conclusion, id)
67
+ if status == 'completed' && ['failure', 'startup_failure', 'timed_out'].include?(conclusion)
68
+ puts "\n#{colorize('Fetching failure logs...', 31)}"
69
+ puts '------------------------'
70
+ system('gh', 'run', 'view', id.to_s, '--log-failed')
71
+ elsif status == 'in_progress'
72
+ puts "\n#{colorize('Build is currently running... ⏳', 34)}"
73
+ puts "You can watch it with: gh run watch #{id}"
74
+ elsif status == 'queued'
75
+ puts "\n#{colorize('Build is queued... 🕒', 34)}"
76
+ end
77
+ end
78
+
79
+ private def colorize(text, color_code)
80
+ "\e[#{color_code}m#{text}\e[0m"
81
+ end
82
+
83
+ private def status_color(status, conclusion)
84
+ if status == 'completed'
85
+ case conclusion
86
+ when 'success' then 32 # Green
87
+ when 'failure', 'startup_failure', 'timed_out' then 31 # Red
88
+ when 'cancelled' then 33 # Yellow
89
+ else 37 # White
90
+ end
91
+ else
92
+ 34 # Blue for in_progress/queued
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end