cov-loupe 3.0.0

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 (171) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +329 -0
  4. data/docs/dev/ARCHITECTURE.md +80 -0
  5. data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
  6. data/docs/dev/DEVELOPMENT.md +83 -0
  7. data/docs/dev/README.md +10 -0
  8. data/docs/dev/RELEASING.md +146 -0
  9. data/docs/dev/arch-decisions/001-x-arch-decision.md +95 -0
  10. data/docs/dev/arch-decisions/002-x-arch-decision.md +159 -0
  11. data/docs/dev/arch-decisions/003-x-arch-decision.md +165 -0
  12. data/docs/dev/arch-decisions/004-x-arch-decision.md +203 -0
  13. data/docs/dev/arch-decisions/005-x-arch-decision.md +189 -0
  14. data/docs/dev/arch-decisions/README.md +60 -0
  15. data/docs/dev/presentations/cov-loupe-presentation.md +255 -0
  16. data/docs/fixtures/demo_project/README.md +9 -0
  17. data/docs/user/ADVANCED_USAGE.md +777 -0
  18. data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
  19. data/docs/user/CLI_USAGE.md +750 -0
  20. data/docs/user/ERROR_HANDLING.md +93 -0
  21. data/docs/user/EXAMPLES.md +588 -0
  22. data/docs/user/INSTALLATION.md +130 -0
  23. data/docs/user/LIBRARY_API.md +693 -0
  24. data/docs/user/MCP_INTEGRATION.md +490 -0
  25. data/docs/user/README.md +14 -0
  26. data/docs/user/TROUBLESHOOTING.md +197 -0
  27. data/docs/user/V2-BREAKING-CHANGES.md +472 -0
  28. data/exe/cov-loupe +23 -0
  29. data/lib/cov_loupe/app_config.rb +56 -0
  30. data/lib/cov_loupe/app_context.rb +26 -0
  31. data/lib/cov_loupe/base_tool.rb +102 -0
  32. data/lib/cov_loupe/cli.rb +178 -0
  33. data/lib/cov_loupe/commands/base_command.rb +67 -0
  34. data/lib/cov_loupe/commands/command_factory.rb +45 -0
  35. data/lib/cov_loupe/commands/detailed_command.rb +38 -0
  36. data/lib/cov_loupe/commands/list_command.rb +13 -0
  37. data/lib/cov_loupe/commands/raw_command.rb +38 -0
  38. data/lib/cov_loupe/commands/summary_command.rb +41 -0
  39. data/lib/cov_loupe/commands/totals_command.rb +53 -0
  40. data/lib/cov_loupe/commands/uncovered_command.rb +45 -0
  41. data/lib/cov_loupe/commands/validate_command.rb +60 -0
  42. data/lib/cov_loupe/commands/version_command.rb +33 -0
  43. data/lib/cov_loupe/config_parser.rb +32 -0
  44. data/lib/cov_loupe/constants.rb +22 -0
  45. data/lib/cov_loupe/coverage_reporter.rb +31 -0
  46. data/lib/cov_loupe/error_handler.rb +165 -0
  47. data/lib/cov_loupe/error_handler_factory.rb +31 -0
  48. data/lib/cov_loupe/errors.rb +191 -0
  49. data/lib/cov_loupe/formatters/source_formatter.rb +152 -0
  50. data/lib/cov_loupe/formatters.rb +51 -0
  51. data/lib/cov_loupe/mcp_server.rb +42 -0
  52. data/lib/cov_loupe/mode_detector.rb +56 -0
  53. data/lib/cov_loupe/model.rb +339 -0
  54. data/lib/cov_loupe/option_normalizers.rb +113 -0
  55. data/lib/cov_loupe/option_parser_builder.rb +147 -0
  56. data/lib/cov_loupe/option_parsers/env_options_parser.rb +48 -0
  57. data/lib/cov_loupe/option_parsers/error_helper.rb +110 -0
  58. data/lib/cov_loupe/path_relativizer.rb +64 -0
  59. data/lib/cov_loupe/predicate_evaluator.rb +72 -0
  60. data/lib/cov_loupe/presenters/base_coverage_presenter.rb +42 -0
  61. data/lib/cov_loupe/presenters/coverage_detailed_presenter.rb +14 -0
  62. data/lib/cov_loupe/presenters/coverage_raw_presenter.rb +14 -0
  63. data/lib/cov_loupe/presenters/coverage_summary_presenter.rb +14 -0
  64. data/lib/cov_loupe/presenters/coverage_uncovered_presenter.rb +14 -0
  65. data/lib/cov_loupe/presenters/project_coverage_presenter.rb +50 -0
  66. data/lib/cov_loupe/presenters/project_totals_presenter.rb +27 -0
  67. data/lib/cov_loupe/resolvers/coverage_line_resolver.rb +122 -0
  68. data/lib/cov_loupe/resolvers/resolver_factory.rb +28 -0
  69. data/lib/cov_loupe/resolvers/resultset_path_resolver.rb +76 -0
  70. data/lib/cov_loupe/resultset_loader.rb +131 -0
  71. data/lib/cov_loupe/staleness_checker.rb +247 -0
  72. data/lib/cov_loupe/table_formatter.rb +64 -0
  73. data/lib/cov_loupe/tools/all_files_coverage_tool.rb +51 -0
  74. data/lib/cov_loupe/tools/coverage_detailed_tool.rb +35 -0
  75. data/lib/cov_loupe/tools/coverage_raw_tool.rb +34 -0
  76. data/lib/cov_loupe/tools/coverage_summary_tool.rb +34 -0
  77. data/lib/cov_loupe/tools/coverage_table_tool.rb +50 -0
  78. data/lib/cov_loupe/tools/coverage_totals_tool.rb +44 -0
  79. data/lib/cov_loupe/tools/help_tool.rb +115 -0
  80. data/lib/cov_loupe/tools/uncovered_lines_tool.rb +34 -0
  81. data/lib/cov_loupe/tools/validate_tool.rb +72 -0
  82. data/lib/cov_loupe/tools/version_tool.rb +32 -0
  83. data/lib/cov_loupe/util.rb +88 -0
  84. data/lib/cov_loupe/version.rb +5 -0
  85. data/lib/cov_loupe.rb +140 -0
  86. data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
  87. data/spec/TIMESTAMPS.md +48 -0
  88. data/spec/all_files_coverage_tool_spec.rb +53 -0
  89. data/spec/app_config_spec.rb +142 -0
  90. data/spec/base_tool_spec.rb +62 -0
  91. data/spec/cli/show_default_report_spec.rb +33 -0
  92. data/spec/cli_enumerated_options_spec.rb +90 -0
  93. data/spec/cli_error_spec.rb +184 -0
  94. data/spec/cli_format_spec.rb +123 -0
  95. data/spec/cli_json_options_spec.rb +50 -0
  96. data/spec/cli_source_spec.rb +44 -0
  97. data/spec/cli_spec.rb +192 -0
  98. data/spec/cli_table_spec.rb +28 -0
  99. data/spec/cli_usage_spec.rb +42 -0
  100. data/spec/commands/base_command_spec.rb +107 -0
  101. data/spec/commands/command_factory_spec.rb +76 -0
  102. data/spec/commands/detailed_command_spec.rb +34 -0
  103. data/spec/commands/list_command_spec.rb +28 -0
  104. data/spec/commands/raw_command_spec.rb +69 -0
  105. data/spec/commands/summary_command_spec.rb +34 -0
  106. data/spec/commands/totals_command_spec.rb +34 -0
  107. data/spec/commands/uncovered_command_spec.rb +55 -0
  108. data/spec/commands/validate_command_spec.rb +213 -0
  109. data/spec/commands/version_command_spec.rb +38 -0
  110. data/spec/constants_spec.rb +61 -0
  111. data/spec/cov_loupe/formatters/source_formatter_spec.rb +267 -0
  112. data/spec/cov_loupe/formatters_spec.rb +76 -0
  113. data/spec/cov_loupe/presenters/base_coverage_presenter_spec.rb +79 -0
  114. data/spec/cov_loupe_model_spec.rb +454 -0
  115. data/spec/cov_loupe_module_spec.rb +37 -0
  116. data/spec/cov_loupe_opts_spec.rb +185 -0
  117. data/spec/coverage_reporter_spec.rb +102 -0
  118. data/spec/coverage_table_tool_spec.rb +59 -0
  119. data/spec/coverage_totals_tool_spec.rb +37 -0
  120. data/spec/error_handler_spec.rb +197 -0
  121. data/spec/error_mode_spec.rb +139 -0
  122. data/spec/errors_edge_cases_spec.rb +312 -0
  123. data/spec/errors_stale_spec.rb +83 -0
  124. data/spec/file_based_mcp_tools_spec.rb +99 -0
  125. data/spec/fixtures/project1/lib/bar.rb +5 -0
  126. data/spec/fixtures/project1/lib/foo.rb +6 -0
  127. data/spec/help_tool_spec.rb +26 -0
  128. data/spec/integration_spec.rb +789 -0
  129. data/spec/logging_fallback_spec.rb +128 -0
  130. data/spec/mcp_logging_spec.rb +44 -0
  131. data/spec/mcp_server_integration_spec.rb +23 -0
  132. data/spec/mcp_server_spec.rb +106 -0
  133. data/spec/mode_detector_spec.rb +153 -0
  134. data/spec/model_error_handling_spec.rb +269 -0
  135. data/spec/model_staleness_spec.rb +79 -0
  136. data/spec/option_normalizers_spec.rb +203 -0
  137. data/spec/option_parsers/env_options_parser_spec.rb +221 -0
  138. data/spec/option_parsers/error_helper_spec.rb +222 -0
  139. data/spec/path_relativizer_spec.rb +98 -0
  140. data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
  141. data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
  142. data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
  143. data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
  144. data/spec/presenters/project_coverage_presenter_spec.rb +87 -0
  145. data/spec/presenters/project_totals_presenter_spec.rb +144 -0
  146. data/spec/resolvers/coverage_line_resolver_spec.rb +282 -0
  147. data/spec/resolvers/resolver_factory_spec.rb +61 -0
  148. data/spec/resolvers/resultset_path_resolver_spec.rb +60 -0
  149. data/spec/resultset_loader_spec.rb +167 -0
  150. data/spec/shared_examples/README.md +115 -0
  151. data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
  152. data/spec/shared_examples/file_based_mcp_tools.rb +179 -0
  153. data/spec/shared_examples/formatted_command_examples.rb +64 -0
  154. data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
  155. data/spec/spec_helper.rb +127 -0
  156. data/spec/staleness_checker_spec.rb +374 -0
  157. data/spec/staleness_more_spec.rb +42 -0
  158. data/spec/support/cli_helpers.rb +22 -0
  159. data/spec/support/control_flow_helpers.rb +20 -0
  160. data/spec/support/fake_mcp.rb +40 -0
  161. data/spec/support/io_helpers.rb +29 -0
  162. data/spec/support/mcp_helpers.rb +35 -0
  163. data/spec/support/mcp_runner.rb +66 -0
  164. data/spec/support/mocking_helpers.rb +30 -0
  165. data/spec/table_format_spec.rb +70 -0
  166. data/spec/tools/validate_tool_spec.rb +132 -0
  167. data/spec/tools_error_handling_spec.rb +130 -0
  168. data/spec/util_spec.rb +154 -0
  169. data/spec/version_spec.rb +123 -0
  170. data/spec/version_tool_spec.rb +141 -0
  171. metadata +290 -0
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'constants'
4
+
5
+ module CovLoupe
6
+ # Centralizes the logic for detecting whether to run in CLI or MCP server mode.
7
+ # This makes the mode detection strategy explicit and testable.
8
+ class ModeDetector
9
+ SUBCOMMANDS = %w[list summary raw uncovered detailed totals validate version].freeze
10
+
11
+ # Reference shared constant to avoid duplication with CoverageCLI
12
+ OPTIONS_EXPECTING_ARGUMENT = Constants::OPTIONS_EXPECTING_ARGUMENT
13
+
14
+ def self.cli_mode?(argv, stdin: $stdin)
15
+ # 1. Explicit flags that force CLI mode always win
16
+ cli_options = %w[--force-cli -h --help --version -v]
17
+ return true if argv.intersect?(cli_options)
18
+
19
+
20
+ # 2. Find the first non-option argument
21
+ first_non_option = find_first_non_option(argv)
22
+
23
+ # 3. If a non-option argument exists, it must be a CLI command (or an error)
24
+ return true if first_non_option
25
+
26
+ # 4. Fallback: If no non-option args, use TTY status to decide
27
+ stdin.tty?
28
+ end
29
+
30
+ def self.mcp_server_mode?(argv, stdin: $stdin)
31
+ !cli_mode?(argv, stdin: stdin)
32
+ end
33
+
34
+ # Scans argv and returns the first token that is not an option or a value for an option.
35
+ def self.find_first_non_option(argv)
36
+ pending_option = false
37
+ argv.each do |token|
38
+ if pending_option
39
+ pending_option = false
40
+ next
41
+ end
42
+
43
+ if token.start_with?('-')
44
+ # Check if the option is one that takes a value and isn't using '=' syntax.
45
+ pending_option = OPTIONS_EXPECTING_ARGUMENT.include?(token) && !token.include?('=')
46
+ next
47
+ end
48
+
49
+ # Found the first token that is not an option
50
+ return token
51
+ end
52
+ nil
53
+ end
54
+ private_class_method :find_first_non_option
55
+ end
56
+ end
@@ -0,0 +1,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'json'
5
+
6
+ require_relative 'util'
7
+ require_relative 'errors'
8
+ require_relative 'error_handler'
9
+ require_relative 'staleness_checker'
10
+ require_relative 'path_relativizer'
11
+ require_relative 'resultset_loader'
12
+
13
+ module CovLoupe
14
+ class CoverageModel
15
+ RELATIVIZER_SCALAR_KEYS = %w[file file_path].freeze
16
+ RELATIVIZER_ARRAY_KEYS = %w[newer_files missing_files deleted_files].freeze
17
+
18
+ attr_reader :relativizer
19
+
20
+ # Create a CoverageModel
21
+ #
22
+ # Params:
23
+ # - root: project root directory (default '.')
24
+ # - resultset: path or directory to .resultset.json
25
+ # - staleness: :off or :error (default :off). When :error, raises
26
+ # stale errors if sources are newer than coverage or line counts mismatch.
27
+ # - tracked_globs: only used for all_files project-level staleness.
28
+ def initialize(root: '.', resultset: nil, staleness: :off, tracked_globs: nil)
29
+ @root = File.absolute_path(root || '.')
30
+ @resultset = resultset
31
+ @relativizer = PathRelativizer.new(
32
+ root: @root,
33
+ scalar_keys: RELATIVIZER_SCALAR_KEYS,
34
+ array_keys: RELATIVIZER_ARRAY_KEYS
35
+ )
36
+
37
+ load_coverage_data(resultset, staleness, tracked_globs)
38
+ end
39
+
40
+ # Returns { 'file' => <absolute_path>, 'lines' => [hits|nil,...] }
41
+ def raw_for(path)
42
+ file_abs, coverage_lines = coverage_data_for(path)
43
+ { 'file' => file_abs, 'lines' => coverage_lines }
44
+ end
45
+
46
+ def relativize(payload)
47
+ relativizer.relativize(payload)
48
+ end
49
+
50
+ # Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, 'percentage'=>} }
51
+ def summary_for(path)
52
+ file_abs, coverage_lines = coverage_data_for(path)
53
+ { 'file' => file_abs, 'summary' => CovUtil.summary(coverage_lines) }
54
+ end
55
+
56
+ # Returns { 'file' => <absolute_path>, 'uncovered' => [line,...], 'summary' => {...} }
57
+ def uncovered_for(path)
58
+ file_abs, coverage_lines = coverage_data_for(path)
59
+ {
60
+ 'file' => file_abs,
61
+ 'uncovered' => CovUtil.uncovered(coverage_lines),
62
+ 'summary' => CovUtil.summary(coverage_lines)
63
+ }
64
+ end
65
+
66
+ # Returns { 'file' => <absolute_path>, 'lines' => [{'line'=>,'hits'=>,'covered'=>},...], 'summary' => {...} }
67
+ def detailed_for(path)
68
+ file_abs, coverage_lines = coverage_data_for(path)
69
+ {
70
+ 'file' => file_abs,
71
+ 'lines' => CovUtil.detailed(coverage_lines),
72
+ 'summary' => CovUtil.summary(coverage_lines)
73
+ }
74
+ end
75
+
76
+ # Returns [ { 'file' =>, 'covered' =>, 'total' =>, 'percentage' =>, 'stale' => }, ... ]
77
+ def all_files(sort_order: :descending, check_stale: !@checker.off?, tracked_globs: nil)
78
+ stale_checker = build_staleness_checker(mode: :off, tracked_globs: tracked_globs)
79
+
80
+ rows = @cov.map do |abs_path, _data|
81
+ begin
82
+ coverage_lines = CovUtil.lookup_lines(@cov, abs_path)
83
+ rescue FileError
84
+ next
85
+ end
86
+
87
+ s = CovUtil.summary(coverage_lines)
88
+ stale = stale_checker.stale_for_file?(abs_path, coverage_lines)
89
+ {
90
+ 'file' => abs_path,
91
+ 'covered' => s['covered'],
92
+ 'total' => s['total'],
93
+ 'percentage' => s['percentage'],
94
+ 'stale' => stale
95
+ }
96
+ end.compact
97
+
98
+ rows = filter_rows_by_globs(rows, tracked_globs)
99
+
100
+ if check_stale
101
+ build_staleness_checker(mode: :error, tracked_globs: tracked_globs).check_project!(@cov)
102
+ end
103
+
104
+ sort_rows(rows, sort_order: sort_order)
105
+ end
106
+
107
+ def project_totals(tracked_globs: nil, check_stale: !@checker.off?)
108
+ rows = all_files(sort_order: :ascending, check_stale: check_stale,
109
+ tracked_globs: tracked_globs)
110
+ totals_from_rows(rows)
111
+ end
112
+
113
+ def staleness_for(path)
114
+ file_abs = File.absolute_path(path, @root)
115
+ coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
116
+ @checker.stale_for_file?(file_abs, coverage_lines)
117
+ rescue => e
118
+ CovUtil.safe_log("Failed to check staleness for #{path}: #{e.message}")
119
+ false
120
+ end
121
+
122
+ # Returns formatted table string for all files coverage data
123
+ def format_table(rows = nil, sort_order: :descending, check_stale: !@checker.off?,
124
+ tracked_globs: nil)
125
+ rows = prepare_rows(rows, sort_order: sort_order, check_stale: check_stale,
126
+ tracked_globs: tracked_globs)
127
+ return 'No coverage data found' if rows.empty?
128
+
129
+ widths = compute_table_widths(rows)
130
+ lines = []
131
+ lines << border_line(widths, '┌', '┬', '┐')
132
+ lines << header_row(widths)
133
+ lines << border_line(widths, '├', '┼', '┤')
134
+ rows.each { |file_data| lines << data_row(file_data, widths) }
135
+ lines << border_line(widths, '└', '┴', '┘')
136
+ lines << summary_counts(rows)
137
+ if rows.any? { |f| f['stale'] }
138
+ lines <<
139
+ 'Staleness: M = Missing file, T = Timestamp (source newer), L = Line count mismatch'
140
+ end
141
+ lines.join("\n")
142
+ end
143
+
144
+ private def load_coverage_data(resultset, staleness, tracked_globs)
145
+ rs = CovUtil.find_resultset(@root, resultset: resultset)
146
+ loaded = ResultsetLoader.load(resultset_path: rs)
147
+ coverage_map = loaded.coverage_map or raise(CoverageDataError, "No 'coverage' key found in resultset file: #{rs}")
148
+
149
+ @cov = coverage_map.transform_keys { |k| File.absolute_path(k, @root) }
150
+ @cov_timestamp = loaded.timestamp
151
+
152
+ @checker = StalenessChecker.new(
153
+ root: @root,
154
+ resultset: @resultset,
155
+ mode: staleness,
156
+ tracked_globs: tracked_globs,
157
+ timestamp: @cov_timestamp
158
+ )
159
+ rescue CovLoupe::Error
160
+ raise # Re-raise our own errors as-is
161
+ rescue => e
162
+ raise ErrorHandler.new.convert_standard_error(e, context: :coverage_loading)
163
+ end
164
+
165
+ private def build_staleness_checker(mode:, tracked_globs:)
166
+ StalenessChecker.new(
167
+ root: @root,
168
+ resultset: @resultset,
169
+ mode: mode,
170
+ tracked_globs: tracked_globs,
171
+ timestamp: @cov_timestamp
172
+ )
173
+ end
174
+
175
+ private def prepare_rows(rows, sort_order:, check_stale:, tracked_globs:)
176
+ if rows.nil?
177
+ all_files(sort_order: sort_order, check_stale: check_stale, tracked_globs: tracked_globs)
178
+ else
179
+ rows = sort_rows(rows.dup, sort_order: sort_order)
180
+ filter_rows_by_globs(rows, tracked_globs)
181
+ end
182
+ end
183
+
184
+ private def sort_rows(rows, sort_order: :descending)
185
+ rows.sort do |a, b|
186
+ pct_cmp = (sort_order == :descending) \
187
+ ? (b['percentage'] <=> a['percentage'])
188
+ : (a['percentage'] <=> b['percentage'])
189
+ pct_cmp == 0 ? (a['file'] <=> b['file']) : pct_cmp
190
+ end
191
+ end
192
+
193
+ private def compute_table_widths(rows)
194
+ max_file_length = rows.map { |f| f['file'].length }.max.to_i
195
+ file_width = [max_file_length, 'File'.length].max + 2
196
+ pct_width = 8
197
+ max_covered = rows.map { |f| f['covered'].to_s.length }.max
198
+ max_total = rows.map { |f| f['total'].to_s.length }.max
199
+ covered_width = [max_covered, 'Covered'.length].max + 2
200
+ total_width = [max_total, 'Total'.length].max + 2
201
+ stale_width = 'Stale'.length
202
+ {
203
+ file: file_width,
204
+ pct: pct_width,
205
+ covered: covered_width,
206
+ total: total_width,
207
+ stale: stale_width
208
+ }
209
+ end
210
+
211
+ private def border_line(widths, left, middle, right)
212
+ h_line = ->(col_width) { '─' * (col_width + 2) }
213
+ left +
214
+ h_line.call(widths[:file]) +
215
+ middle + h_line.call(widths[:pct]) +
216
+ middle + h_line.call(widths[:covered]) +
217
+ middle + h_line.call(widths[:total]) +
218
+ middle + h_line.call(widths[:stale]) +
219
+ right
220
+ end
221
+
222
+ private def header_row(widths)
223
+ format(
224
+ "│ %-#{widths[:file]}s │ %#{widths[:pct]}s │ %#{widths[:covered]}s │ %#{widths[:total]}s │ %#{widths[:stale]}s │",
225
+ 'File', ' %', 'Covered', 'Total', 'Stale'.center(widths[:stale])
226
+ )
227
+ end
228
+
229
+ private def data_row(file_data, widths)
230
+ stale_text_str = file_data['stale'] ? file_data['stale'].to_s : ''
231
+ format(
232
+ "│ %-#{widths[:file]}s │ %#{widths[:pct] - 1}.2f%% │ %#{widths[:covered]}d │ %#{widths[:total]}d │ %#{widths[:stale]}s │",
233
+ file_data['file'],
234
+ file_data['percentage'],
235
+ file_data['covered'],
236
+ file_data['total'],
237
+ stale_text_str.center(widths[:stale])
238
+ )
239
+ end
240
+
241
+ private def summary_counts(rows)
242
+ total = rows.length
243
+ stale_count = rows.count { |f| f['stale'] }
244
+ ok_count = total - stale_count
245
+ "Files: total #{total}, ok #{ok_count}, stale #{stale_count}"
246
+ end
247
+
248
+ # Filters coverage rows to only include files matching the given glob patterns.
249
+ #
250
+ # @param rows [Array<Hash>] coverage rows with 'file' keys containing absolute paths
251
+ # @param tracked_globs [Array<String>, String, nil] glob patterns to match against
252
+ # @return [Array<Hash>] rows whose files match at least one pattern (or all rows if no patterns)
253
+ private def filter_rows_by_globs(rows, tracked_globs)
254
+ patterns = Array(tracked_globs).compact.map(&:to_s).reject(&:empty?)
255
+ return rows if patterns.empty?
256
+
257
+ absolute_patterns = patterns.map { |p| absolutize_pattern(p) }
258
+ rows.select { |row| matches_any_pattern?(row['file'], absolute_patterns) }
259
+ end
260
+
261
+ # Converts a relative pattern to absolute by joining with root.
262
+ # Absolute patterns are returned unchanged.
263
+ #
264
+ # @param pattern [String] glob pattern (e.g., "lib/**/*.rb")
265
+ # @return [String] absolute pattern
266
+ private def absolutize_pattern(pattern)
267
+ absolute_pattern?(pattern) ? pattern : File.join(@root, pattern)
268
+ end
269
+
270
+ # Checks if a pattern is absolute, handling both Unix and Windows-style paths.
271
+ # On Unix, Pathname won't recognize "C:/" as absolute, so we check explicitly.
272
+ #
273
+ # @param pattern [String] glob pattern
274
+ # @return [Boolean] true if pattern is absolute
275
+ private def absolute_pattern?(pattern)
276
+ Pathname.new(pattern).absolute? || pattern.match?(/\A[A-Za-z]:/)
277
+ end
278
+
279
+ # Tests if a file path matches any of the given absolute glob patterns.
280
+ # Uses File.fnmatch? for pure string matching without filesystem access,
281
+ # which is faster and works for paths that may no longer exist on disk.
282
+ #
283
+ # @param abs_path [String] absolute file path to test
284
+ # @param patterns [Array<String>] absolute glob patterns
285
+ # @return [Boolean] true if the path matches at least one pattern
286
+ private def matches_any_pattern?(abs_path, patterns)
287
+ flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
288
+ patterns.any? { |pattern| File.fnmatch?(pattern, abs_path, flags) }
289
+ end
290
+
291
+ # Retrieves coverage data for a file path.
292
+ # Converts the path to absolute form and performs staleness checking if enabled.
293
+ #
294
+ # @param path [String] relative or absolute file path
295
+ # @return [Array(String, Array)] tuple of [absolute_path, coverage_lines]
296
+ # @raise [FileError] if no coverage data exists for the file
297
+ # @raise [FileNotFoundError] if the file does not exist
298
+ # @raise [CoverageDataStaleError] if staleness checking is enabled and data is stale
299
+ private def coverage_data_for(path)
300
+ file_abs = File.absolute_path(path, @root)
301
+ begin
302
+ coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
303
+ rescue RuntimeError
304
+ raise FileError, "No coverage data found for file: #{path}"
305
+ end
306
+ @checker.check_file!(file_abs, coverage_lines) unless @checker.off?
307
+ if coverage_lines.nil?
308
+ raise FileError, "No coverage data found for file: #{path}"
309
+ end
310
+
311
+ [file_abs, coverage_lines]
312
+ rescue Errno::ENOENT
313
+ raise FileNotFoundError, "File not found: #{path}"
314
+ end
315
+
316
+ private def totals_from_rows(rows)
317
+ covered = rows.sum { |row| row['covered'].to_i }
318
+ total = rows.sum { |row| row['total'].to_i }
319
+ uncovered = total - covered
320
+ percentage = total.zero? ? 100.0 : ((covered.to_f * 100.0 / total) * 100).round / 100.0
321
+ stale_count = rows.count { |row| row['stale'] }
322
+ files_total = rows.length
323
+
324
+ {
325
+ 'lines' => {
326
+ 'covered' => covered,
327
+ 'uncovered' => uncovered,
328
+ 'total' => total
329
+ },
330
+ 'percentage' => percentage,
331
+ 'files' => {
332
+ 'total' => files_total,
333
+ 'ok' => files_total - stale_count,
334
+ 'stale' => stale_count
335
+ }
336
+ }
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CovLoupe
4
+ # Shared normalization logic for CLI options.
5
+ # Provides both strict (raise on invalid) and lenient (default on invalid) modes.
6
+ module OptionNormalizers
7
+ SORT_ORDER_MAP = {
8
+ 'a' => :ascending,
9
+ 'ascending' => :ascending,
10
+ 'd' => :descending,
11
+ 'descending' => :descending
12
+ }.freeze
13
+
14
+ SOURCE_MODE_MAP = {
15
+ 'f' => :full,
16
+ 'full' => :full,
17
+ 'u' => :uncovered,
18
+ 'uncovered' => :uncovered
19
+ }.freeze
20
+
21
+ STALENESS_MAP = {
22
+ 'o' => :off,
23
+ 'off' => :off,
24
+ 'e' => :error,
25
+ 'error' => :error
26
+ }.freeze
27
+
28
+ ERROR_MODE_MAP = {
29
+ 'off' => :off,
30
+ 'o' => :off,
31
+ 'log' => :log,
32
+ 'l' => :log,
33
+ 'debug' => :debug,
34
+ 'd' => :debug
35
+ }.freeze
36
+
37
+ FORMAT_MAP = {
38
+ 't' => :table,
39
+ 'table' => :table,
40
+ 'j' => :json,
41
+ 'json' => :json,
42
+ 'J' => :pretty_json,
43
+ 'pretty_json' => :pretty_json,
44
+ 'pretty-json' => :pretty_json,
45
+ 'y' => :yaml,
46
+ 'yaml' => :yaml,
47
+ 'a' => :awesome_print,
48
+ 'awesome_print' => :awesome_print,
49
+ 'ap' => :awesome_print
50
+ }.freeze
51
+
52
+ module_function def normalize_sort_order(value, strict: true)
53
+ normalized = SORT_ORDER_MAP[value.to_s.downcase]
54
+ return normalized if normalized
55
+ raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
56
+
57
+ nil
58
+ end
59
+
60
+ # Normalize source mode value.
61
+ # @param value [String, Symbol, nil] The value to normalize
62
+ # @param strict [Boolean] If true, raises on invalid value; if false, returns nil
63
+ # @return [Symbol, nil] The normalized symbol or nil if invalid and not strict
64
+ # @raise [OptionParser::InvalidArgument] If strict and value is invalid
65
+ module_function def normalize_source_mode(value, strict: true)
66
+ normalized = SOURCE_MODE_MAP[value.to_s.downcase]
67
+ return normalized if normalized
68
+ raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
69
+
70
+ nil
71
+ end
72
+
73
+ # Normalize stale mode value.
74
+ # @param value [String, Symbol] The value to normalize
75
+ # @param strict [Boolean] If true, raises on invalid value; if false, returns nil
76
+ # @return [Symbol, nil] The normalized symbol or nil if invalid and not strict
77
+ # @raise [OptionParser::InvalidArgument] If strict and value is invalid
78
+ module_function def normalize_staleness(value, strict: true)
79
+ normalized = STALENESS_MAP[value.to_s.downcase]
80
+ return normalized if normalized
81
+ raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
82
+
83
+ nil
84
+ end
85
+
86
+ # Normalize error mode value.
87
+ # @param value [String, Symbol, nil] The value to normalize
88
+ # @param strict [Boolean] If true, raises on invalid value; if false, returns default
89
+ # @param default [Symbol] The default value to return if invalid and not strict
90
+ # @return [Symbol] The normalized symbol or default if invalid and not strict
91
+ # @raise [OptionParser::InvalidArgument] If strict and value is invalid
92
+ module_function def normalize_error_mode(value, strict: true, default: :log)
93
+ normalized = ERROR_MODE_MAP[value.to_s.downcase]
94
+ return normalized if normalized
95
+ raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
96
+
97
+ default
98
+ end
99
+
100
+ # Normalize format value.
101
+ # @param value [String, Symbol] The value to normalize
102
+ # @param strict [Boolean] If true, raises on invalid value; if false, returns nil
103
+ # @return [Symbol, nil] The normalized symbol or nil if invalid and not strict
104
+ # @raise [OptionParser::InvalidArgument] If strict and value is invalid
105
+ module_function def normalize_format(value, strict: true)
106
+ normalized = FORMAT_MAP[value.to_s.downcase]
107
+ return normalized if normalized
108
+ raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
109
+
110
+ nil
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'option_normalizers'
4
+ require_relative 'version'
5
+
6
+ module CovLoupe
7
+ class OptionParserBuilder
8
+ HORIZONTAL_RULE = '-' * 79
9
+ SUBCOMMANDS = %w[list summary raw uncovered detailed totals validate version].freeze
10
+
11
+ attr_reader :config
12
+
13
+ def initialize(config)
14
+ @config = config
15
+ end
16
+
17
+ def build_option_parser
18
+ require 'optparse'
19
+ OptionParser.new do |parser|
20
+ configure_banner(parser)
21
+ define_subcommands_help(parser)
22
+ define_options(parser)
23
+ define_examples(parser)
24
+ end
25
+ end
26
+
27
+ private def configure_banner(parser)
28
+ parser.banner = <<~BANNER
29
+ #{HORIZONTAL_RULE}
30
+ Usage: cov-loupe [options] [subcommand] [args] (default subcommand: list)
31
+ Repository: https://github.com/keithrbennett/cov-loupe
32
+ Version: #{CovLoupe::VERSION}
33
+ #{HORIZONTAL_RULE}
34
+
35
+ BANNER
36
+ end
37
+
38
+ private def define_subcommands_help(parser)
39
+ parser.separator <<~SUBCOMMANDS
40
+ Subcommands:
41
+ detailed <path> Show per-line rows with hits/covered
42
+ list Show files coverage (default: table, or use --format)
43
+ raw <path> Show the SimpleCov 'lines' array
44
+ summary <path> Show covered/total/% for a file
45
+ totals Show aggregated line totals and average %
46
+ uncovered <path> Show uncovered lines and a summary
47
+ validate <file> Evaluate coverage policy from file (exit 0=pass, 1=fail, 2=error)
48
+ validate -e <code> Evaluate coverage policy from code string
49
+ version Show version information
50
+
51
+ SUBCOMMANDS
52
+ end
53
+
54
+ private def define_options(parser)
55
+ parser.separator 'Options:'
56
+ parser.on('-r', '--resultset PATH', String,
57
+ 'Path or directory that contains .resultset.json (default: coverage/.resultset.json)') \
58
+ do |value|
59
+ config.resultset = value
60
+ end
61
+ parser.on('-R', '--root PATH', String, 'Project root (default: .)') do |value|
62
+ config.root = value
63
+ end
64
+ parser.on(
65
+ '-f', '--format FORMAT', String,
66
+ 'Output format: t[able]|j[son]|pretty-json|y[aml]|a[wesome-print] (default: table)'
67
+ ) do |value|
68
+ config.format = normalize_format(value)
69
+ end
70
+ parser.on('-o', '--sort-order ORDER', String,
71
+ 'Sort order for list: a[scending]|d[escending] (default descending)') do |value|
72
+ config.sort_order = normalize_sort_order(value)
73
+ end
74
+ parser.on('-s', '--source MODE', String,
75
+ 'Source display: f[ull]|u[ncovered]') do |value|
76
+ config.source_mode = normalize_source_mode(value)
77
+ end
78
+ parser.on('-c', '--context-lines N', Integer,
79
+ 'Context lines around uncovered lines (non-negative, default: 2)') do |value|
80
+ config.source_context = value
81
+ end
82
+ parser.on('--color', 'Enable ANSI colors for source output') { config.color = true }
83
+ parser.on('--no-color', 'Disable ANSI colors') { config.color = false }
84
+ parser.on('-S', '--staleness MODE', String,
85
+ 'Staleness detection: o[ff]|e[rror] (default off)') do |value|
86
+ config.staleness = normalize_staleness(value)
87
+ end
88
+ parser.on('-g', '--tracked-globs x,y,z', Array,
89
+ 'Globs for filtering files (list/totals subcommands)') do |value|
90
+ config.tracked_globs = value
91
+ end
92
+ parser.on('-h', '--help', 'Show help') do
93
+ puts parser
94
+ gem_root = File.expand_path('../..', __dir__)
95
+ puts "\nFor more detailed help, consult README.md and docs/user/**/*.md"
96
+ puts "in the installed gem at: #{gem_root}"
97
+ exit 0
98
+ end
99
+ parser.on('-l', '--log-file PATH', String,
100
+ 'Log file path (default ./cov_loupe.log, use stdout/stderr for streams)') do |value|
101
+ config.log_file = value
102
+ end
103
+ parser.on('--error-mode MODE', String,
104
+ 'Error handling mode: o[ff]|l[og]|d[ebug] (default log). ' \
105
+ 'off (silent), log (log errors to file), debug (verbose with backtraces)') do |value|
106
+ config.error_mode = normalize_error_mode(value)
107
+ end
108
+ parser.on('--force-cli', 'Force CLI mode (useful in scripts where auto-detection fails)') do
109
+ # This flag is mainly for mode detection - no action needed here
110
+ end
111
+ parser.on('-v', '--version', 'Show version information and exit') do
112
+ config.show_version = true
113
+ end
114
+ end
115
+
116
+ private def define_examples(parser)
117
+ parser.separator <<~EXAMPLES
118
+
119
+ Examples:
120
+ cov-loupe --resultset coverage list
121
+ cov-loupe --format json --resultset coverage summary lib/foo.rb
122
+ cov-loupe --source uncovered --context-lines 2 uncovered lib/foo.rb
123
+ cov-loupe totals --format json
124
+ EXAMPLES
125
+ end
126
+
127
+ private def normalize_sort_order(value)
128
+ OptionNormalizers.normalize_sort_order(value, strict: true)
129
+ end
130
+
131
+ private def normalize_source_mode(value)
132
+ OptionNormalizers.normalize_source_mode(value, strict: true)
133
+ end
134
+
135
+ private def normalize_staleness(value)
136
+ OptionNormalizers.normalize_staleness(value, strict: true)
137
+ end
138
+
139
+ private def normalize_error_mode(value)
140
+ OptionNormalizers.normalize_error_mode(value, strict: true)
141
+ end
142
+
143
+ private def normalize_format(value)
144
+ OptionNormalizers.normalize_format(value, strict: true)
145
+ end
146
+ end
147
+ end