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,520 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'json'
5
+ require 'set' # rubocop:disable Lint/RedundantRequireStatement -- Ruby >= 3.4 requires explicit require for set; RuboCop targets 3.2
6
+
7
+ require_relative '../errors/errors'
8
+ require_relative '../errors/error_handler'
9
+ require_relative '../staleness/staleness_checker'
10
+ require_relative '../staleness/stale_status'
11
+ require_relative '../paths/path_relativizer'
12
+ require_relative '../loaders/resultset_loader'
13
+ require_relative '../coverage/coverage_table_formatter'
14
+ require_relative '../coverage/coverage_calculator'
15
+ require_relative '../resolvers/resolver_helpers'
16
+ require_relative '../paths/glob_utils'
17
+ require_relative '../model/model_data_cache'
18
+ require_relative '../paths/path_utils'
19
+
20
+ module CovLoupe
21
+ class CoverageModel
22
+ RELATIVIZER_SCALAR_KEYS = %w[file file_path].freeze
23
+ RELATIVIZER_ARRAY_KEYS = %w[
24
+ newer_files
25
+ deleted_files
26
+ missing_tracked_files
27
+ skipped_files
28
+ length_mismatch_files
29
+ unreadable_files
30
+ ].freeze
31
+
32
+ DEFAULT_SORT_ORDER = :descending
33
+
34
+ attr_reader :relativizer, :skipped_rows, :volume_case_sensitive
35
+
36
+ # Create a CoverageModel
37
+ #
38
+ # Params:
39
+ # - root: project root directory (default '.')
40
+ # - resultset: path or directory to .resultset.json
41
+ # - raise_on_stale: boolean (default false). When true, raises
42
+ # stale errors if sources are newer than coverage or line counts mismatch.
43
+ # - tracked_globs: array of glob patterns (default []). Used for filtering and tracking.
44
+ # - logger: logger instance (defaults to CovLoupe.logger)
45
+ def initialize(root: '.', resultset: nil, raise_on_stale: false, tracked_globs: [],
46
+ logger: nil)
47
+ @root = File.expand_path(root || '.')
48
+ @resultset_arg = resultset
49
+ @default_tracked_globs = tracked_globs
50
+ @skipped_rows = []
51
+ @logger = logger || CovLoupe.logger
52
+ @relativizer = PathRelativizer.new(
53
+ root: @root,
54
+ scalar_keys: RELATIVIZER_SCALAR_KEYS,
55
+ array_keys: RELATIVIZER_ARRAY_KEYS
56
+ )
57
+ @default_raise_on_stale = raise_on_stale
58
+ @resolved_resultset_path = nil # Resolved on first fetch
59
+
60
+ # Eagerly validate resultset exists and load initial data
61
+ # This matches original behavior and surfaces errors immediately
62
+ begin
63
+ data = fetch_data
64
+ @resultset_path = data.resultset_path
65
+ rescue CovLoupe::Error
66
+ raise # Re-raise our own errors as-is
67
+ rescue => e
68
+ raise ErrorHandler.new.convert_standard_error(e, context: :coverage_loading)
69
+ end
70
+
71
+ # Compute volume case sensitivity based on this model's root directory
72
+ # This is not cached because different models may use the same resultset
73
+ # with different root directories on different volumes
74
+ @volume_case_sensitive = PathUtils.volume_case_sensitive?(@root)
75
+ end
76
+
77
+ # Returns { 'file' => <absolute_path>, 'lines' => [hits|nil,...] }
78
+ def raw_for(path, raise_on_stale: @default_raise_on_stale)
79
+ file_abs, coverage_lines = coverage_data_for(path, raise_on_stale: raise_on_stale)
80
+ { 'file' => file_abs, 'lines' => coverage_lines }
81
+ end
82
+
83
+ def relativize(payload)
84
+ relativizer.relativize(payload)
85
+ end
86
+
87
+ # Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, 'percentage'=>} }
88
+ def summary_for(path, raise_on_stale: @default_raise_on_stale)
89
+ file_abs, coverage_lines = coverage_data_for(path, raise_on_stale: raise_on_stale)
90
+ { 'file' => file_abs, 'summary' => CoverageCalculator.summary(coverage_lines) }
91
+ end
92
+
93
+ # Returns { 'file' => <absolute_path>, 'uncovered' => [line,...], 'summary' => {...} }
94
+ def uncovered_for(path, raise_on_stale: @default_raise_on_stale)
95
+ file_abs, coverage_lines = coverage_data_for(path, raise_on_stale: raise_on_stale)
96
+ {
97
+ 'file' => file_abs,
98
+ 'uncovered' => CoverageCalculator.uncovered(coverage_lines),
99
+ 'summary' => CoverageCalculator.summary(coverage_lines)
100
+ }
101
+ end
102
+
103
+ # Returns { 'file' => <absolute_path>, 'lines' => [{'line'=>,'hits'=>,'covered'=>},...], 'summary' => {...} }
104
+ def detailed_for(path, raise_on_stale: @default_raise_on_stale)
105
+ file_abs, coverage_lines = coverage_data_for(path, raise_on_stale: raise_on_stale)
106
+ {
107
+ 'file' => file_abs,
108
+ 'lines' => CoverageCalculator.detailed(coverage_lines),
109
+ 'summary' => CoverageCalculator.summary(coverage_lines)
110
+ }
111
+ end
112
+
113
+ # Returns [ { 'file' =>, 'covered' =>, 'total' =>, 'percentage' =>, 'stale' => }, ... ]
114
+ def list(sort_order: DEFAULT_SORT_ORDER,
115
+ raise_on_stale: @default_raise_on_stale,
116
+ tracked_globs: @default_tracked_globs)
117
+ @skipped_rows = []
118
+ # Build rows in lenient mode to collect all data even if some files have errors
119
+ # This ensures staleness checking can examine all files, not just the ones before
120
+ # the first error. We'll re-raise any errors after staleness checking if needed.
121
+ rows, coverage_lines_by_path = build_list_rows(
122
+ tracked_globs: tracked_globs,
123
+ raise_on_stale: false # Always use lenient mode for row building
124
+ )
125
+ project_staleness_details = project_staleness_report(
126
+ tracked_globs: tracked_globs,
127
+ raise_on_stale: raise_on_stale, # Honor raise_on_stale for staleness checks
128
+ coverage_lines_by_path: coverage_lines_by_path
129
+ )
130
+
131
+ # If raise_on_stale is true and there were any skipped files with errors,
132
+ # raise the first error encountered after staleness checking is complete
133
+ if raise_on_stale && @skipped_rows.any?
134
+ first_error = @skipped_rows.first
135
+ error_class = Object.const_get(first_error['error_class'])
136
+ raise error_class, first_error['error']
137
+ end
138
+
139
+ file_statuses = project_staleness_details[:file_statuses] || {}
140
+ length_mismatch_files = Array(project_staleness_details[:length_mismatch_files]).uniq
141
+ unreadable_files = Array(project_staleness_details[:unreadable_files]).uniq
142
+ rows.each do |row|
143
+ row['stale'] = file_statuses.fetch(row['file'], 'ok')
144
+ end
145
+
146
+ {
147
+ 'files' => sort_rows(rows, sort_order: sort_order),
148
+ 'skipped_files' => filter_rows_by_globs(@skipped_rows, tracked_globs),
149
+ 'missing_tracked_files' => project_staleness_details[:missing_files],
150
+ 'newer_files' => project_staleness_details[:newer_files],
151
+ 'deleted_files' => project_staleness_details[:deleted_files],
152
+ 'length_mismatch_files' => length_mismatch_files,
153
+ 'unreadable_files' => unreadable_files,
154
+ 'timestamp_status' => project_staleness_details[:timestamp_status]
155
+ }
156
+ end
157
+
158
+ def project_totals(
159
+ tracked_globs: @default_tracked_globs, raise_on_stale: @default_raise_on_stale
160
+ )
161
+ list_result = list(sort_order: :ascending, raise_on_stale: raise_on_stale,
162
+ tracked_globs: tracked_globs)
163
+
164
+ rows = list_result['files']
165
+
166
+ included_rows = rows.reject { |row| StaleStatus.stale?(row['stale']) }
167
+ line_totals = line_totals_from_rows(included_rows)
168
+
169
+ tracking = tracking_payload(tracked_globs)
170
+ with_coverage = with_coverage_payload(rows)
171
+ without_coverage = without_coverage_payload(list_result, tracking['enabled'])
172
+ files = files_payload(with_coverage, without_coverage)
173
+
174
+ {
175
+ 'lines' => line_totals,
176
+ 'tracking' => tracking,
177
+ 'files' => files,
178
+ 'timestamp_status' => list_result['timestamp_status']
179
+ }
180
+ end
181
+
182
+ def staleness_for(path)
183
+ file_abs = File.expand_path(path, @root)
184
+ coverage_lines = Resolvers::ResolverHelpers.lookup_lines(coverage_map, file_abs, root: @root,
185
+ volume_case_sensitive: volume_case_sensitive)
186
+ build_staleness_checker(raise_on_stale: false, tracked_globs: nil)
187
+ .file_staleness_status(file_abs, coverage_lines)
188
+ rescue => e
189
+ @logger.safe_log("Failed to check staleness for #{path}: #{e.message}")
190
+ 'error'
191
+ end
192
+
193
+ # Returns formatted table string for all files coverage data
194
+ # Delegates to CoverageTableFormatter for presentation logic
195
+ #
196
+ # @param rows [Array<Hash>, nil] Pre-computed rows, or nil to compute from coverage data
197
+ # @param sort_order [Symbol] Sort order (:ascending or :descending)
198
+ # @param raise_on_stale [Boolean] Whether to raise on stale coverage data
199
+ # @param tracked_globs [Array<String>, nil] Glob patterns for tracked files
200
+ # @param output_chars [Symbol] Output character mode (:default, :fancy, :ascii)
201
+ # @return [String] Formatted table
202
+ def format_table(rows = nil, sort_order: DEFAULT_SORT_ORDER,
203
+ raise_on_stale: @default_raise_on_stale,
204
+ tracked_globs: @default_tracked_globs,
205
+ output_chars: :default)
206
+ rows = prepare_rows(rows, sort_order: sort_order, raise_on_stale: raise_on_stale,
207
+ tracked_globs: tracked_globs)
208
+ CoverageTableFormatter.format(rows, output_chars: output_chars)
209
+ end
210
+
211
+ # Lazily resolves the resultset path on first access
212
+ private def resolved_resultset_path
213
+ @resolved_resultset_path ||= Resolvers::ResolverHelpers.find_resultset(
214
+ @root, resultset: @resultset_arg
215
+ )
216
+ end
217
+
218
+ # Fetches current ModelData from the shared cache
219
+ # The cache automatically reloads if the resultset file has changed
220
+ private def fetch_data
221
+ ModelDataCache.instance.get(resolved_resultset_path, root: @root, logger: @logger)
222
+ end
223
+
224
+ # Returns the coverage map by delegating to ModelDataCache.
225
+ # The cache automatically reloads if the resultset file has changed.
226
+ private def coverage_map
227
+ fetch_data.coverage_map
228
+ end
229
+
230
+ # Returns the timestamp by delegating to ModelDataCache.
231
+ # The cache automatically reloads if the resultset file has changed.
232
+ private def coverage_timestamp
233
+ fetch_data.timestamp
234
+ end
235
+
236
+ # Clears the resolved resultset path to allow re-resolution.
237
+ # ModelDataCache automatically handles resultset file changes on each access,
238
+ # so explicit refresh is rarely needed. This method is primarily for testing.
239
+ def refresh_data
240
+ @resolved_resultset_path = nil
241
+ self
242
+ end
243
+
244
+ private def build_staleness_checker(raise_on_stale:, tracked_globs:)
245
+ StalenessChecker.new(
246
+ root: @root,
247
+ resultset: resolved_resultset_path,
248
+ mode: raise_on_stale ? :error : :off,
249
+ tracked_globs: tracked_globs,
250
+ timestamp: coverage_timestamp
251
+ )
252
+ end
253
+
254
+ private def build_list_rows(tracked_globs:, raise_on_stale:)
255
+ coverage_lines_by_path = {}
256
+ rows = coverage_map.filter_map do |abs_path, entry|
257
+ # Extract lines directly from the entry to avoid O(n^2) resolver scans
258
+ coverage_lines = coverage_lines_for_listing(abs_path, entry, raise_on_stale)
259
+ next unless coverage_lines
260
+
261
+ coverage_lines_by_path[abs_path] = coverage_lines
262
+ summary = CoverageCalculator.summary(coverage_lines)
263
+ {
264
+ 'file' => abs_path,
265
+ 'covered' => summary['covered'],
266
+ 'total' => summary['total'],
267
+ 'percentage' => summary['percentage'],
268
+
269
+ # We set 'stale' => 'ok' as a placeholder, then in list we overwrite it
270
+ # with the true status from the project report.
271
+ 'stale' => 'ok'
272
+ }
273
+ end
274
+
275
+ [filter_rows_by_globs(rows, tracked_globs), coverage_lines_by_path]
276
+ end
277
+
278
+ private def coverage_lines_for_listing(abs_path, entry, raise_on_stale)
279
+ # Try to extract lines directly from the entry (O(1) operation)
280
+ # Only fall back to resolver if the entry is malformed
281
+ lines = extract_lines_from_entry(entry)
282
+ return lines if lines
283
+
284
+ # Fallback to resolver for malformed entries
285
+ Resolvers::ResolverHelpers.lookup_lines(coverage_map, abs_path, root: @root,
286
+ volume_case_sensitive: volume_case_sensitive)
287
+ rescue FileError, CoverageDataError => e
288
+ # When raise_on_stale is true, raise all errors immediately for strict validation
289
+ # When false, skip files with errors and report them in skipped_files for lenient mode
290
+ raise e if raise_on_stale
291
+
292
+ @logger.safe_log("Skipping coverage row for #{abs_path}: #{e.message}")
293
+ @skipped_rows << {
294
+ 'file' => abs_path,
295
+ 'error' => e.message,
296
+ 'error_class' => e.class.name
297
+ }
298
+ nil
299
+ end
300
+
301
+ private def project_staleness_report(tracked_globs:, raise_on_stale:, coverage_lines_by_path:)
302
+ # Filter coverage files to match the same scope as tracked_globs
303
+ coverage_files = GlobUtils.filter_paths(coverage_map.keys, tracked_globs, root: @root)
304
+
305
+ # Filter coverage_lines_by_path to the same scope to ensure length-mismatch
306
+ # checks only apply to files within the tracked_globs scope
307
+ coverage_files_set = coverage_files.to_set
308
+ scoped_coverage_lines = coverage_lines_by_path.slice(*coverage_files_set)
309
+
310
+ build_staleness_checker(
311
+ raise_on_stale: raise_on_stale, tracked_globs: tracked_globs
312
+ ).check_project_with_lines!(scoped_coverage_lines, coverage_files: coverage_files)
313
+ end
314
+
315
+ private def prepare_rows(rows, sort_order:, raise_on_stale:, tracked_globs:)
316
+ files = rows || list(sort_order: sort_order, raise_on_stale: raise_on_stale,
317
+ tracked_globs: tracked_globs)['files']
318
+
319
+ files = sort_rows(files.dup, sort_order: sort_order)
320
+ filter_rows_by_globs(files, tracked_globs)
321
+ end
322
+
323
+ private def sort_rows(rows, sort_order: :descending)
324
+ percent_comparator = sort_order == :descending \
325
+ ? ->(left, right) { right <=> left }
326
+ : ->(left, right) { left <=> right }
327
+
328
+ nil_comparator = ->(left, right) do
329
+ left_nil = left['percentage'].nil?
330
+ right_nil = right['percentage'].nil?
331
+ return 0 if left_nil == right_nil
332
+
333
+ if sort_order == :descending
334
+ left_nil ? -1 : 1
335
+ else
336
+ left_nil ? 1 : -1
337
+ end
338
+ end
339
+
340
+ comparator = ->(a, b) do
341
+ nil_comparison = nil_comparator.call(a, b)
342
+ return nil_comparison unless nil_comparison.zero?
343
+
344
+ if !a['percentage'].nil? && !b['percentage'].nil?
345
+ percent_comp_result = percent_comparator.(a['percentage'], b['percentage'])
346
+ return percent_comp_result if percent_comp_result != 0
347
+ end
348
+
349
+ a['file'] <=> b['file']
350
+ end
351
+
352
+ rows.sort { |a, b| comparator.(a, b) }
353
+ end
354
+
355
+ # Filters coverage rows to only include files matching the given glob patterns.
356
+ #
357
+ # @param rows [Array<Hash>] coverage rows with 'file' keys containing absolute paths
358
+ # @param tracked_globs [Array<String>, String, nil] glob patterns to match against
359
+ # @return [Array<Hash>] rows whose files match at least one pattern (or all rows if no patterns)
360
+ private def filter_rows_by_globs(rows, tracked_globs)
361
+ patterns = GlobUtils.normalize_patterns(tracked_globs)
362
+ return rows if patterns.empty?
363
+
364
+ absolute_patterns = patterns.map { |p| GlobUtils.absolutize_pattern(p, @root) }
365
+ GlobUtils.filter_by_pattern(rows, absolute_patterns)
366
+ end
367
+
368
+ # Retrieves coverage data for a file path.
369
+ # Converts the path to absolute form and performs staleness checking if enabled.
370
+ #
371
+ # @param path [String] relative or absolute file path
372
+ # @return [Array(String, Array)] tuple of [absolute_path, coverage_lines]
373
+ # @raise [FileError] if no coverage data exists for the file
374
+ # @raise [FileNotFoundError] if the file does not exist and raise_on_stale is true
375
+ # @raise [CoverageDataStaleError] if staleness checking is enabled and data is stale
376
+ private def coverage_data_for(path, raise_on_stale: @default_raise_on_stale)
377
+ file_abs = File.expand_path(path, @root)
378
+ coverage_lines = Resolvers::ResolverHelpers.lookup_lines(coverage_map, file_abs, root: @root,
379
+ volume_case_sensitive: volume_case_sensitive)
380
+
381
+ if coverage_lines.nil?
382
+ raise FileError, "No coverage data found for file: #{path}"
383
+ end
384
+
385
+ # When raise_on_stale is false, allow missing files to return coverage data
386
+ # The staleness status will be added by the presenter via staleness_for
387
+ if raise_on_stale && !File.file?(file_abs)
388
+ raise FileNotFoundError, "File not found: #{path}"
389
+ end
390
+
391
+ checker = build_staleness_checker(raise_on_stale: raise_on_stale, tracked_globs: nil)
392
+ checker.check_file!(file_abs, coverage_lines) unless checker.off?
393
+
394
+ [file_abs, coverage_lines]
395
+ rescue Errno::ENOENT
396
+ raise FileNotFoundError, "File not found: #{path}" if raise_on_stale
397
+
398
+ [file_abs, coverage_lines]
399
+ end
400
+
401
+ private def line_totals_from_rows(rows)
402
+ covered = rows.sum { |row| row['covered'].to_i }
403
+ total = rows.sum { |row| row['total'].to_i }
404
+ uncovered = total - covered
405
+ percent_covered = total.zero? ? nil : ((covered.to_f * 100.0 / total) * 100).round / 100.0
406
+
407
+ {
408
+ 'covered' => covered,
409
+ 'uncovered' => uncovered,
410
+ 'total' => total,
411
+ 'percent_covered' => percent_covered
412
+ }
413
+ end
414
+
415
+ private def tracking_payload(tracked_globs)
416
+ patterns = GlobUtils.normalize_patterns(tracked_globs)
417
+ {
418
+ 'enabled' => patterns.any?,
419
+ 'globs' => patterns
420
+ }
421
+ end
422
+
423
+ private def with_coverage_payload(rows)
424
+ breakdown = stale_breakdown(rows)
425
+ stale_by_type = breakdown[:stale_by_type]
426
+ stale_total = stale_by_type.values.sum
427
+
428
+ {
429
+ 'total' => rows.length,
430
+ 'ok' => breakdown[:ok],
431
+ 'stale' => {
432
+ 'total' => stale_total,
433
+ 'by_type' => stale_by_type
434
+ }
435
+ }
436
+ end
437
+
438
+ private def without_coverage_payload(list_result, tracking_enabled)
439
+ return nil unless tracking_enabled
440
+
441
+ missing_from_coverage = Array(list_result['missing_tracked_files']).length
442
+ skipped = Array(list_result['skipped_files']).length
443
+ unreadable = Array(list_result['unreadable_files']).length
444
+ by_type = {
445
+ 'missing_from_coverage' => missing_from_coverage,
446
+ 'unreadable' => unreadable,
447
+ 'skipped' => skipped
448
+ }
449
+ {
450
+ 'total' => by_type.values.sum,
451
+ 'by_type' => by_type
452
+ }
453
+ end
454
+
455
+ private def files_payload(with_coverage, without_coverage)
456
+ total = with_coverage['total']
457
+ total += without_coverage['total'] if without_coverage
458
+
459
+ files = {
460
+ 'total' => total,
461
+ 'with_coverage' => with_coverage
462
+ }
463
+ files['without_coverage'] = without_coverage if without_coverage
464
+ files
465
+ end
466
+
467
+ private def stale_breakdown(rows)
468
+ stale_by_type = {
469
+ 'missing_from_disk' => 0,
470
+ 'newer' => 0,
471
+ 'length_mismatch' => 0,
472
+ 'unreadable' => 0
473
+ }
474
+ ok_files = 0
475
+
476
+ rows.each do |row|
477
+ case row['stale']
478
+ when 'ok'
479
+ ok_files += 1
480
+ when 'missing'
481
+ stale_by_type['missing_from_disk'] += 1
482
+ when 'newer'
483
+ stale_by_type['newer'] += 1
484
+ when 'length_mismatch'
485
+ stale_by_type['length_mismatch'] += 1
486
+ when 'error'
487
+ stale_by_type['unreadable'] += 1
488
+ end
489
+ end
490
+
491
+ {
492
+ ok: ok_files,
493
+ stale_by_type: stale_by_type
494
+ }
495
+ end
496
+
497
+ # Extract coverage lines from a SimpleCov entry.
498
+ # Returns nil if the entry is not a valid Hash, does not contain a lines array,
499
+ # or contains invalid elements. Invalid entries trigger fallback to the resolver,
500
+ # which performs centralized validation and error handling.
501
+ #
502
+ # @param entry [Hash, Object] coverage entry from the resultset
503
+ # @return [Array<Integer, nil>, nil] SimpleCov-style line coverage array or nil
504
+ private def extract_lines_from_entry(entry)
505
+ return unless entry.is_a?(Hash)
506
+
507
+ lines = entry['lines']
508
+ unless lines.is_a?(Array)
509
+ @logger.safe_log("Invalid coverage lines encountered (not an array): #{lines.class}") if lines
510
+ return nil
511
+ end
512
+
513
+ # Validate all elements - return nil to trigger resolver fallback on validation failure
514
+ # The resolver will raise CoverageDataError with a detailed message
515
+ return nil unless lines.all? { |v| v.nil? || v.is_a?(Integer) }
516
+
517
+ lines
518
+ end
519
+ end
520
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CovLoupe
4
+ # Immutable data container for coverage data loaded from a specific resultset file.
5
+ # Holds the normalized coverage map, timestamp, and resultset path.
6
+ #
7
+ # This class has no awareness of caching - it's managed by ModelDataCache.
8
+ #
9
+ # @attr_reader coverage_map [Hash] Map of absolute file paths to coverage data
10
+ # @attr_reader timestamp [Integer] Latest timestamp from coverage suites
11
+ # @attr_reader resultset_path [String] Absolute path to the .resultset.json file
12
+ ModelData = Data.define(:coverage_map, :timestamp, :resultset_path)
13
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require_relative 'model_data'
5
+ require_relative '../repositories/coverage_repository'
6
+
7
+ module CovLoupe
8
+ # Thread-safe singleton cache for ModelData instances.
9
+ # Entries are keyed by [resultset_path, root] and automatically invalidated when the file changes.
10
+ #
11
+ # On every get() call, the cache checks the resultset file's signature (mtime/size/inode)
12
+ # and digest (MD5) to ensure the data is current. If the file has changed, fresh data
13
+ # is loaded automatically.
14
+ #
15
+ # The cache key includes both resultset_path and root because path normalization and
16
+ # case-sensitivity detection depend on the root directory. Two models with the same
17
+ # resultset but different roots may have different normalized coverage maps.
18
+ class ModelDataCache
19
+ # Mutex for thread-safe singleton initialization.
20
+ # Using a constant ensures it cannot be reset, avoiding race conditions in JRuby.
21
+ INSTANCE_MUTEX = Mutex.new
22
+
23
+ def initialize
24
+ @entries = {}
25
+ @mutex = Mutex.new
26
+ end
27
+
28
+ # Returns the singleton instance with thread-safe initialization
29
+ def self.instance
30
+ INSTANCE_MUTEX.synchronize do
31
+ @instance ||= new
32
+ end
33
+ end
34
+
35
+ # Fetches ModelData for the given resultset path.
36
+ # Checks signature/digest on every call and reloads if the file has changed.
37
+ #
38
+ # @param resultset_path [String] Absolute path to .resultset.json
39
+ # @param root [String] Project root directory for path normalization
40
+ # @param logger [Logger, nil] Logger instance for data loading operations
41
+ # @return [ModelData] The cached or freshly loaded data
42
+ def get(resultset_path, root:, logger: nil)
43
+ @mutex.synchronize do
44
+ # Cache key must include both resultset_path and root because
45
+ # path normalization and case-sensitivity depend on the root
46
+ cache_key = [resultset_path, root]
47
+ entry = @entries[cache_key]
48
+
49
+ # Compute current signature and digest
50
+ signature = compute_signature(resultset_path)
51
+ digest = compute_digest(resultset_path)
52
+
53
+ # Return cached data if it matches
54
+ if entry && signature && digest &&
55
+ entry[:signature] == signature &&
56
+ entry[:digest] == digest
57
+ return entry[:data]
58
+ end
59
+
60
+ # Load fresh data using the provided logger
61
+ data = load_data(resultset_path, root, logger)
62
+
63
+ # Store with signature/digest if we computed them
64
+ if signature && digest
65
+ @entries[cache_key] = {
66
+ data: data,
67
+ signature: signature,
68
+ digest: digest
69
+ }
70
+ end
71
+
72
+ data
73
+ end
74
+ end
75
+
76
+ # Clears all cached entries (primarily for testing)
77
+ def clear
78
+ @mutex.synchronize { @entries.clear }
79
+ end
80
+
81
+ private def load_data(resultset_path, root, logger)
82
+ repo = Repositories::CoverageRepository.new(
83
+ root: root,
84
+ resultset_path: resultset_path,
85
+ logger: logger || CovLoupe.logger
86
+ )
87
+
88
+ ModelData.new(
89
+ coverage_map: repo.coverage_map,
90
+ timestamp: repo.timestamp,
91
+ resultset_path: resultset_path
92
+ )
93
+ end
94
+
95
+ private def compute_signature(resultset_path)
96
+ stat = File.stat(resultset_path)
97
+ {
98
+ mtime: stat.mtime,
99
+ mtime_nsec: stat.respond_to?(:mtime_nsec) ? stat.mtime_nsec : stat.mtime.nsec,
100
+ size: stat.size,
101
+ inode: stat.respond_to?(:ino) ? stat.ino : nil
102
+ }.compact
103
+ rescue Errno::ENOENT, Errno::EACCES
104
+ nil
105
+ end
106
+
107
+ # Compute a fast digest of the resultset file.
108
+ # Uses MD5 which is fast and sufficient for cache validation
109
+ # (we don't need cryptographic security, just change detection).
110
+ private def compute_digest(resultset_path)
111
+ Digest::MD5.file(resultset_path).hexdigest
112
+ rescue Errno::ENOENT, Errno::EACCES
113
+ nil
114
+ end
115
+ end
116
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'shellwords'
4
- require_relative '../option_normalizers'
4
+ require_relative '../config/option_normalizers'
5
5
 
6
6
  module CovLoupe
7
7
  module OptionParsers
@@ -24,15 +24,26 @@ module CovLoupe
24
24
  end
25
25
 
26
26
  def pre_scan_error_mode(argv, error_mode_normalizer: method(:normalize_error_mode))
27
- # Quick scan for --error-mode to ensure early errors are logged correctly
28
- argv.each_with_index do |arg, i|
29
- if arg == '--error-mode' && argv[i + 1]
27
+ # Quick scan for --error-mode or -e to ensure early errors are logged correctly.
28
+ # Scan in reverse so that the last occurrence (CLI) overrides earlier ones (ENV).
29
+ i = argv.length - 1
30
+ while i >= 0
31
+ arg = argv[i]
32
+ if %w[--error-mode -e].include?(arg) && argv[i + 1]
30
33
  return error_mode_normalizer.call(argv[i + 1])
34
+
31
35
  elsif arg.start_with?('--error-mode=')
32
36
  value = arg.split('=', 2)[1]
33
- return nil if value.to_s.empty?
34
- return error_mode_normalizer.call(value) if value
37
+ return error_mode_normalizer.call(value)
38
+
39
+ elsif arg.start_with?('-e') && arg.length > 2
40
+ # Handle attached short option: -edebug
41
+ value = arg[2..]
42
+ return error_mode_normalizer.call(value)
43
+
35
44
  end
45
+
46
+ i -= 1
36
47
  end
37
48
  nil
38
49
  rescue