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,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'pathname'
6
+ require 'time'
7
+ require_relative 'command_execution'
8
+
9
+ module CovLoupe
10
+ module Scripts
11
+ class PreReleaseCheck
12
+ include CommandExecution
13
+
14
+ ROOT = Pathname.new(__dir__).join('../../..').expand_path
15
+
16
+ def call
17
+ Dir.chdir(ROOT) do
18
+ verify_git_clean!
19
+ puts '✓ Git working tree is clean'
20
+
21
+ verify_branch!
22
+ puts '✓ On main branch'
23
+
24
+ verify_sync!
25
+ puts '✓ Local branch is in sync with origin/main'
26
+
27
+ verify_ci_passed!
28
+ puts '✓ GitHub Actions CI passed'
29
+
30
+ @version = fetch_version
31
+ @tag_name = "v#{@version}"
32
+ puts "✓ Preparing release for version #{@version}"
33
+
34
+ verify_release_notes!
35
+ puts "✓ Release notes found for #{@tag_name}"
36
+
37
+ verify_tag_new!
38
+ puts "✓ Tag #{@tag_name} does not yet exist"
39
+
40
+ build_gem!
41
+ puts '✓ Gem built successfully'
42
+
43
+ puts "\nBuild complete! To finish the release, run:"
44
+ puts
45
+ puts " git tag -a #{@tag_name} -m 'Version #{@version}'"
46
+ puts ' git push origin main --follow-tags'
47
+ puts " gem push #{@gem_file.basename}"
48
+ puts
49
+ puts 'Then draft the GitHub release via the web UI.'
50
+ end
51
+ end
52
+
53
+ private def verify_git_clean!
54
+ status = run_command(%w[git status --porcelain], print_output: false)
55
+ unless status.strip.empty?
56
+ abort_with('Uncommitted changes present. Commit or stash before releasing.')
57
+ end
58
+ end
59
+
60
+ private def verify_branch!
61
+ current_branch = run_command(%w[git rev-parse --abbrev-ref HEAD], print_output: false).strip
62
+ abort_with('Releases must be cut from the main branch.') unless current_branch == 'main'
63
+ end
64
+
65
+ private def verify_sync!
66
+ run_command(%w[git fetch origin --tags], print_output: true)
67
+ local = run_command(%w[git rev-parse HEAD], print_output: false).strip
68
+ remote = run_command(%w[git rev-parse origin/main], print_output: false).strip
69
+ return if local == remote
70
+
71
+ base = run_command(%w[git merge-base HEAD origin/main], print_output: false).strip
72
+
73
+ if base == local
74
+ abort_with('Local main is behind origin. Pull before releasing.')
75
+ elsif base == remote
76
+ abort_with('Local main is ahead of origin. Push before releasing.')
77
+ else
78
+ abort_with('Local main has diverged from origin. Reconcile before releasing.')
79
+ end
80
+ end
81
+
82
+ private def verify_ci_passed!
83
+ # Capture current HEAD SHA and timestamp before triggering
84
+ head_sha = run_command(%w[git rev-parse HEAD], print_output: false).strip
85
+ trigger_time = Time.now
86
+
87
+ # Trigger the workflow
88
+ run_command(%w[gh workflow run test.yml --ref main], print_output: true)
89
+ puts 'Waiting for workflow to initialize...'
90
+
91
+ # Poll for the specific workflow run matching HEAD SHA and created after trigger time
92
+ run_id = find_triggered_run_id(head_sha, trigger_time)
93
+ abort_with('Failed to retrieve the CI run ID.') if run_id.empty?
94
+
95
+ puts "Monitoring CI build (Run ID: #{run_id})..."
96
+ run_command(['gh', 'run', 'watch', run_id, '--exit-status'], print_output: true)
97
+ end
98
+
99
+ private def find_triggered_run_id(head_sha, trigger_time)
100
+ max_attempts = 30
101
+ poll_interval = 2
102
+ attempts = 0
103
+
104
+ while attempts < max_attempts
105
+ sleep poll_interval
106
+ attempts += 1
107
+
108
+ # Get runs with databaseId, headSha, and createdAt fields
109
+ runs_json = run_command(
110
+ %w[gh run list --workflow test.yml --branch main --limit 10] \
111
+ + %w[--json databaseId,headSha,createdAt],
112
+ print_output: false
113
+ ).strip
114
+
115
+ next if runs_json.empty?
116
+
117
+ begin
118
+ runs = JSON.parse(runs_json)
119
+ # Find the newest run matching our HEAD SHA and created after trigger time
120
+ matching_run = runs.find do |run|
121
+ run['headSha'] == head_sha &&
122
+ Time.parse(run['createdAt']) >= trigger_time
123
+ end
124
+
125
+ return matching_run['databaseId'].to_s if matching_run
126
+ rescue JSON::ParserError => e
127
+ abort_with("Failed to parse GitHub API response: #{e.message}")
128
+ end
129
+ end
130
+
131
+ abort_with("Timed out waiting for workflow run to appear for HEAD SHA #{head_sha}")
132
+ end
133
+
134
+ private def fetch_version
135
+ version_file = ROOT.join('lib/cov_loupe/version.rb')
136
+ version_source = version_file.read
137
+ version = version_source[/VERSION\s*=\s*["'](.+?)["']/, 1]
138
+ abort_with("Could not find VERSION constant in #{version_file}") unless version
139
+ version
140
+ end
141
+
142
+ private def verify_release_notes!
143
+ release_notes = ROOT.join('RELEASE_NOTES.md').read
144
+ version_pattern = /^## .*\b#{Regexp.escape(@tag_name)}\b/
145
+ unless release_notes.match?(version_pattern)
146
+ abort_with("Add a '## #{@tag_name}' section to RELEASE_NOTES.md before releasing.")
147
+ end
148
+ end
149
+
150
+ private def verify_tag_new!
151
+ existing_tag = run_command(['git', 'tag', '-l', @tag_name], print_output: false)
152
+ .split("\n").include?(@tag_name)
153
+ abort_with("Tag #{@tag_name} already exists. Bump the version before releasing.") if existing_tag
154
+ end
155
+
156
+ private def build_gem!
157
+ @gem_file = ROOT.join("cov-loupe-#{@version}.gem")
158
+ FileUtils.rm_f(@gem_file)
159
+ run_command(%w[gem build cov-loupe.gemspec], print_output: true)
160
+ abort_with("Gem file #{@gem_file} not found after build.") unless @gem_file.exist?
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'command_execution'
4
+
5
+ module CovLoupe
6
+ module Scripts
7
+ class SetupDocServer
8
+ include CommandExecution
9
+
10
+ def call
11
+ puts 'Setting up Python virtual environment...'
12
+ run_command(%w[python3 -m venv .venv], print_output: true)
13
+
14
+ puts 'Installing dependencies...'
15
+ # Install using the venv's pip directly
16
+ pip_path = File.exist?('.venv/bin/pip') ? '.venv/bin/pip' : 'pip'
17
+ run_command([pip_path, 'install', '-q', '-r', 'requirements.txt'], print_output: true)
18
+
19
+ puts '✓ Documentation server setup complete.'
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'command_execution'
4
+
5
+ module CovLoupe
6
+ module Scripts
7
+ class StartDocServer
8
+ include CommandExecution
9
+
10
+ def call
11
+ mkdocs_path = File.exist?('.venv/bin/mkdocs') ? '.venv/bin/mkdocs' : 'mkdocs'
12
+
13
+ unless command_exists?(mkdocs_path)
14
+ warn "Error: mkdocs not found. Please run 'bin/set-up-python-for-doc-server' or " \
15
+ "'rake docs:setup' first."
16
+ exit 1
17
+ end
18
+
19
+ puts 'Starting documentation server...'
20
+ exec(mkdocs_path, 'serve')
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CovLoupe
4
+ # Helpers for working with staleness status values.
5
+ module StaleStatus
6
+ VALID_STATUSES = %w[ok missing newer length_mismatch error].freeze
7
+
8
+ module_function def stale?(value)
9
+ normalize(value) != 'ok'
10
+ end
11
+
12
+ module_function def normalize(value)
13
+ raise ArgumentError, 'Stale status is missing' if value.nil?
14
+ unless value.is_a?(String)
15
+ raise ArgumentError, "Stale status must be a String, got #{value.class} (value: #{value.inspect})"
16
+ end
17
+ return value if VALID_STATUSES.include?(value)
18
+
19
+ raise ArgumentError, "Unknown stale status: #{value.inspect}. " \
20
+ "Permitted values: #{VALID_STATUSES.join(', ')}"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'pathname'
5
+ require 'set' # rubocop:disable Lint/RedundantRequireStatement -- Ruby >= 3.4 requires explicit require for set; RuboCop targets 3.2
6
+ require_relative '../errors/errors'
7
+ require_relative '../resolvers/resolver_helpers'
8
+
9
+ module CovLoupe
10
+ # Lightweight service object to check staleness of coverage vs. sources
11
+ class StalenessChecker
12
+ MODES = [:off, :error].freeze
13
+
14
+ def initialize(root:, resultset:, mode: :off, tracked_globs: nil, timestamp: nil)
15
+ @root = File.expand_path(root || '.')
16
+ @resultset = resultset
17
+ @mode = (mode || :off).to_sym
18
+ @tracked_globs = tracked_globs
19
+ @cov_timestamp = timestamp
20
+ @resultset_path = nil
21
+ end
22
+
23
+ def off?
24
+ @mode == :off
25
+ end
26
+
27
+ # Raise CoverageDataStaleError if stale (only in error mode)
28
+ def check_file!(file_abs, coverage_lines)
29
+ return if off?
30
+
31
+ d = compute_file_staleness_details(file_abs, coverage_lines)
32
+
33
+ # Raise FileError if there was a read error
34
+ if d[:read_error]
35
+ raise FileError, "Error reading file: #{rel(file_abs)}"
36
+ end
37
+
38
+ # For single-file checks, missing files with recorded coverage count as stale
39
+ # via length mismatch; project-level checks also handle deleted files explicitly.
40
+ if d[:newer] || d[:len_mismatch]
41
+ raise CoverageDataStaleError.new(
42
+ nil,
43
+ nil,
44
+ file_path: rel(file_abs),
45
+ file_mtime: d[:file_mtime],
46
+ cov_timestamp: d[:coverage_timestamp],
47
+ src_len: d[:src_len],
48
+ cov_len: d[:cov_len],
49
+ resultset_path: resultset_path
50
+ )
51
+ end
52
+ end
53
+
54
+ # Compute the staleness status for a specific file relative to coverage.
55
+ # Ignores mode and never raises. Returns a String:
56
+ # - 'ok' - file is not stale (fresh)
57
+ # - 'missing' - the file is missing/deleted
58
+ # - 'newer' - the file mtime is newer than the coverage timestamp
59
+ # - 'length_mismatch' - the source line count differs from coverage lines array length
60
+ # - 'error' - the file cannot be read due to permission or I/O errors
61
+ def file_staleness_status(file_abs, coverage_lines)
62
+ d = compute_file_staleness_details(file_abs, coverage_lines)
63
+ return 'error' if d[:read_error]
64
+ return 'missing' unless d[:exists]
65
+ return 'newer' if d[:newer]
66
+ return 'length_mismatch' if d[:len_mismatch]
67
+
68
+ 'ok'
69
+ end
70
+
71
+ # Compute and return project staleness details (newer, missing, deleted files).
72
+ # If in error mode, raises CoverageDataProjectStaleError when issues are found.
73
+ # Returns a hash { newer_files: [], missing_files: [], deleted_files: [], unreadable_files: [] }
74
+ def check_project!(coverage_map)
75
+ ts = coverage_timestamp
76
+ coverage_files = coverage_map.keys
77
+
78
+ newer, deleted, unreadable = compute_newer_and_deleted_files(coverage_files, ts)
79
+ missing = compute_missing_files(coverage_files)
80
+
81
+ staleness_details = {
82
+ newer_files: newer,
83
+ missing_files: missing,
84
+ deleted_files: deleted,
85
+ unreadable_files: unreadable,
86
+ timestamp_status: ts.to_i > 0 ? 'ok' : 'missing'
87
+ }
88
+
89
+ if @mode == :error && (newer.any? || missing.any? || deleted.any? || unreadable.any?)
90
+ raise CoverageDataProjectStaleError.new(
91
+ nil,
92
+ nil,
93
+ cov_timestamp: ts,
94
+ newer_files: newer,
95
+ missing_files: missing,
96
+ deleted_files: deleted,
97
+ unreadable_files: unreadable,
98
+ resultset_path: resultset_path
99
+ )
100
+ end
101
+
102
+ staleness_details
103
+ end
104
+
105
+ # Compute and return project staleness details including line-count mismatches.
106
+ # If in error mode, raises CoverageDataProjectStaleError when issues are found.
107
+ # Returns a hash with newer/missing/deleted/mismatched/unreadable files and per-file statuses.
108
+ def check_project_with_lines!(coverage_lines_by_path, coverage_files:)
109
+ coverage_lines_by_path ||= {}
110
+ ts = coverage_timestamp
111
+
112
+ newer, deleted, unreadable = compute_newer_and_deleted_files(coverage_files, ts)
113
+ missing = compute_missing_files(coverage_files)
114
+
115
+ file_statuses = {}
116
+ length_mismatch = []
117
+
118
+ coverage_lines_by_path.each do |abs_path, coverage_lines|
119
+ details = compute_file_staleness_details(abs_path, coverage_lines)
120
+ status = if details[:read_error]
121
+ 'error'
122
+ elsif !details[:exists]
123
+ 'missing'
124
+ elsif details[:newer]
125
+ 'newer'
126
+ elsif details[:len_mismatch]
127
+ 'length_mismatch'
128
+ else
129
+ 'ok'
130
+ end
131
+ file_statuses[abs_path] = status
132
+ unreadable << rel(abs_path) if details[:read_error]
133
+ length_mismatch << rel(abs_path) if details[:len_mismatch] && details[:exists]
134
+ end
135
+
136
+ # Ensure files are not reported as both "newer" and "length mismatch" or "unreadable"
137
+ # Length mismatch and unreadable are the stronger signals for staleness
138
+ newer -= length_mismatch
139
+ newer -= unreadable
140
+
141
+ staleness_details = {
142
+ newer_files: newer,
143
+ missing_files: missing,
144
+ deleted_files: deleted,
145
+ length_mismatch_files: length_mismatch,
146
+ unreadable_files: unreadable,
147
+ file_statuses: file_statuses,
148
+ timestamp_status: ts.to_i > 0 ? 'ok' : 'missing'
149
+ }
150
+
151
+ if @mode == :error && [newer, missing, deleted, length_mismatch, unreadable].any?(&:any?)
152
+ raise CoverageDataProjectStaleError.new(
153
+ nil,
154
+ nil,
155
+ cov_timestamp: ts,
156
+ newer_files: newer,
157
+ missing_files: missing,
158
+ deleted_files: deleted,
159
+ length_mismatch_files: length_mismatch,
160
+ unreadable_files: unreadable,
161
+ resultset_path: resultset_path
162
+ )
163
+ end
164
+
165
+ staleness_details
166
+ end
167
+
168
+ private def compute_newer_and_deleted_files(coverage_files, timestamp)
169
+ existing = []
170
+ deleted_abs = []
171
+ unreadable_abs = []
172
+
173
+ coverage_files.each do |abs|
174
+ if File.file?(abs)
175
+ existing << abs
176
+ else
177
+ deleted_abs << abs
178
+ end
179
+ rescue SystemCallError, IOError
180
+ # Permission denied or other filesystem errors
181
+ unreadable_abs << abs
182
+ end
183
+
184
+ newer = []
185
+ # If timestamp is missing/0, skip newer checks
186
+ check_newer = timestamp.to_i > 0
187
+
188
+ existing.each do |abs|
189
+ newer << rel(abs) if check_newer && File.mtime(abs).to_i > timestamp.to_i
190
+ rescue SystemCallError, IOError
191
+ # Permission denied or other filesystem errors reading mtime
192
+ unreadable_abs << abs
193
+ end
194
+
195
+ deleted = deleted_abs.map { |abs| rel(abs) }
196
+ unreadable = unreadable_abs.map { |abs| rel(abs) }
197
+
198
+ [newer, deleted, unreadable]
199
+ end
200
+
201
+ # Identifies tracked files that are missing from coverage.
202
+ # Returns array of relative paths for files matched by tracked_globs but not in coverage.
203
+ private def compute_missing_files(coverage_files)
204
+ return [] unless @tracked_globs && Array(@tracked_globs).any?
205
+
206
+ patterns = Array(@tracked_globs).map { |g| File.expand_path(g, @root) }
207
+ tracked = patterns
208
+ .flat_map { |p| Dir.glob(p, File::FNM_EXTGLOB | File::FNM_PATHNAME) }
209
+ .select { |p| File.file?(p) }
210
+
211
+ covered_set = coverage_files.to_set
212
+ tracked.reject { |abs| covered_set.include?(abs) }.map { |abs| rel(abs) }
213
+ end
214
+
215
+ private def coverage_timestamp
216
+ @cov_timestamp || 0
217
+ end
218
+
219
+ private def resultset_path
220
+ @resultset_path ||= Resolvers::ResolverHelpers.find_resultset(@root, resultset: @resultset)
221
+ rescue
222
+ nil
223
+ end
224
+
225
+ private def safe_count_lines(path)
226
+ return 0 unless File.file?(path)
227
+
228
+ File.foreach(path).count
229
+ rescue SystemCallError, IOError
230
+ :read_error
231
+ end
232
+
233
+ private def safe_file_state(path)
234
+ exists = false
235
+ file_mtime = nil
236
+ read_error = false
237
+
238
+ begin
239
+ exists = File.file?(path)
240
+ rescue SystemCallError, IOError
241
+ return [false, nil, true]
242
+ end
243
+
244
+ return [false, nil, false] unless exists
245
+
246
+ begin
247
+ file_mtime = File.mtime(path)
248
+ rescue SystemCallError, IOError
249
+ read_error = true
250
+ end
251
+
252
+ [exists, file_mtime, read_error]
253
+ end
254
+
255
+ private def rel(path)
256
+ # Handle relative vs absolute path mismatches that cause ArgumentError
257
+ Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
258
+ rescue ArgumentError
259
+ # Path is outside the project root or has a different prefix type, fall back to absolute path
260
+ path.to_s
261
+ end
262
+
263
+ # Centralized computation of staleness-related details for a single file.
264
+ # Returns a Hash with keys:
265
+ # :exists, :file_mtime, :coverage_timestamp, :cov_len, :src_len, :newer, :len_mismatch, :read_error
266
+ private def compute_file_staleness_details(file_abs, coverage_lines)
267
+ coverage_ts = coverage_timestamp
268
+
269
+ exists, file_mtime, read_error = safe_file_state(file_abs)
270
+
271
+ cov_len = coverage_lines.respond_to?(:length) ? coverage_lines.length : 0
272
+ src_len = (exists && !read_error) ? safe_count_lines(file_abs) : 0
273
+
274
+ # Check if safe_count_lines returned an error sentinel
275
+ read_error ||= src_len == :read_error
276
+ src_len = 0 if read_error
277
+
278
+ # Check if the source file has been modified since coverage was generated
279
+ # Don't check for mismatch if we couldn't read the file
280
+ len_mismatch = read_error ? false : length_mismatch?(cov_len, src_len)
281
+ newer = check_file_newer_than_coverage(file_mtime, coverage_ts, len_mismatch, read_error)
282
+
283
+ {
284
+ exists: exists,
285
+ file_mtime: file_mtime,
286
+ coverage_timestamp: coverage_ts,
287
+ cov_len: cov_len,
288
+ src_len: src_len,
289
+ newer: newer,
290
+ len_mismatch: len_mismatch,
291
+ read_error: read_error
292
+ }
293
+ end
294
+
295
+ # Checks if the source line count differs from the coverage line count.
296
+ #
297
+ # Why this check exists:
298
+ # - When a file is modified after coverage is generated, the line count often changes
299
+ # - A mismatch indicates the coverage data is stale and no longer represents the current file
300
+ # - We only flag as mismatch when coverage data exists (cov_len > 0)
301
+ #
302
+ # Note: Empty coverage (cov_len == 0) is not considered a mismatch, as it may represent
303
+ # files that were never executed or files that are legitimately empty.
304
+ private def length_mismatch?(cov_len, src_len)
305
+ cov_len.positive? && src_len != cov_len
306
+ end
307
+
308
+ # Determines if a file has been modified more recently than the coverage timestamp.
309
+ #
310
+ # Why this check exists:
311
+ # - Files modified after coverage generation may have behavioral changes not captured
312
+ # - However, if there's already a length mismatch or read error, we prioritize that as the staleness indicator
313
+ # - This prevents double-flagging: if lines changed, the file is already stale (length mismatch)
314
+ #
315
+ # The logic: newer &&= !len_mismatch && !read_error means:
316
+ # - If len_mismatch or read_error is true, set newer to false (those take precedence)
317
+ # - This way, staleness is categorized as either 'newer' (time-based), 'length_mismatch' (length-based),
318
+ # or 'error' (read error), not multiple
319
+ private def check_file_newer_than_coverage(file_mtime, coverage_ts, len_mismatch, read_error)
320
+ return false if coverage_ts.to_i <= 0
321
+
322
+ newer = !!(file_mtime && file_mtime.to_i > coverage_ts.to_i)
323
+ # If there's a length mismatch or read error, don't also flag as "newer" - those are more specific
324
+ newer &&= !len_mismatch && !read_error
325
+ newer
326
+ end
327
+ end
328
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../output_chars'
4
+
5
+ module CovLoupe
6
+ # Formatter for stale coverage error messages
7
+ class StalenessMessageFormatter
8
+ def initialize(cov_timestamp:, resultset_path: nil, output_chars: :default)
9
+ @cov_timestamp = cov_timestamp
10
+ @resultset_path = resultset_path
11
+ @output_chars = output_chars
12
+ end
13
+
14
+ def format_project_details(newer_files:, missing_files:, deleted_files:,
15
+ length_mismatch_files:, unreadable_files: [])
16
+ [
17
+ format_coverage_time,
18
+ *format_file_list(newer_files, 'Newer files'),
19
+ *format_file_list(missing_files, 'Missing files', 'new in project, not in coverage'),
20
+ *format_file_list(deleted_files, 'Coverage-only files', 'deleted or moved in project'),
21
+ *format_file_list(length_mismatch_files, 'Line count mismatches'),
22
+ *format_file_list(unreadable_files, 'Unreadable files', 'permission denied or read errors'),
23
+ (@resultset_path ? "\nResultset - #{convert_path(@resultset_path)}" : nil)
24
+ ].compact.join
25
+ end
26
+
27
+ def format_single_file_details(file_path:, file_mtime:, src_len:, cov_len:)
28
+ file_utc, file_local = format_time_both(file_mtime)
29
+ cov_utc, cov_local = format_epoch_both(@cov_timestamp)
30
+ delta_str = format_delta_seconds(file_mtime, @cov_timestamp)
31
+
32
+ details = <<~DETAILS
33
+
34
+ File - time: #{file_utc || 'not found'} (local #{file_local || 'n/a'}), lines: #{src_len}
35
+ Coverage - time: #{cov_utc || 'not found'} (local #{cov_local || 'n/a'}), lines: #{cov_len}
36
+ DETAILS
37
+
38
+ details += "\nDelta - file is #{delta_str} newer than coverage" if delta_str
39
+ details += "\nResultset - #{convert_path(@resultset_path)}" if @resultset_path
40
+ details.chomp
41
+ end
42
+
43
+ private def convert_path(path)
44
+ OutputChars.convert(path, @output_chars)
45
+ end
46
+
47
+ private def format_coverage_time
48
+ cov_utc, cov_local = format_epoch_both(@cov_timestamp)
49
+ "\nCoverage - time: #{cov_utc || 'not found'} (local #{cov_local || 'n/a'})"
50
+ end
51
+
52
+ private def format_file_list(files, label, description = nil)
53
+ return [] if files.empty?
54
+
55
+ desc = description ? " (#{description}, #{files.size}):" : " (#{files.size}):"
56
+ [
57
+ "\n#{label}#{desc}",
58
+ *files.first(10).map { |f| " - #{convert_path(f)}" },
59
+ *(files.size > 10 ? [' ...'] : [])
60
+ ]
61
+ end
62
+
63
+ private def format_epoch_both(epoch_seconds)
64
+ return [nil, nil] unless epoch_seconds
65
+
66
+ t = Time.at(epoch_seconds.to_i)
67
+ [t.utc.iso8601, t.getlocal.iso8601]
68
+ rescue
69
+ [epoch_seconds.to_s, epoch_seconds.to_s]
70
+ end
71
+
72
+ private def format_time_both(time)
73
+ return [nil, nil] unless time
74
+
75
+ t = time.is_a?(Time) ? time : Time.parse(time.to_s)
76
+ [t.utc.iso8601, t.getlocal.iso8601]
77
+ rescue
78
+ [time.to_s, time.to_s]
79
+ end
80
+
81
+ private def format_delta_seconds(file_mtime, cov_timestamp)
82
+ return nil unless file_mtime && cov_timestamp
83
+
84
+ seconds = file_mtime.to_i - cov_timestamp.to_i
85
+ sign = seconds >= 0 ? '+' : '-'
86
+ "#{sign}#{seconds.abs}s"
87
+ rescue
88
+ nil
89
+ end
90
+ end
91
+ end