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,693 @@
1
+ # Library API Guide
2
+
3
+ [Back to main README](../README.md)
4
+
5
+ Use this gem programmatically to inspect coverage without running the CLI or MCP server. The primary entry point is `CovLoupe::CoverageModel`.
6
+
7
+ ## Table of Contents
8
+
9
+ - [Quick Start](#quick-start)
10
+ - [Method Reference](#method-reference)
11
+ - [Return Types](#return-types)
12
+ - [Error Handling](#error-handling)
13
+ - [Advanced Recipes](#advanced-recipes)
14
+ - [API Stability](#api-stability)
15
+
16
+ ## Quick Start
17
+
18
+ ```ruby
19
+ require "cov_loupe"
20
+
21
+ # Defaults (omit args; shown here with comments):
22
+ # - root: "."
23
+ # - resultset: resolved from common paths under root
24
+ # - staleness: "off" (no stale checks)
25
+ # - tracked_globs: nil (no project-level file-set checks)
26
+ model = CovLoupe::CoverageModel.new
27
+
28
+ # Custom configuration (non-default values):
29
+ model = CovLoupe::CoverageModel.new(
30
+ root: "/path/to/project", # non-default project root
31
+ resultset: "build/coverage", # file or directory containing .resultset.json
32
+ staleness: "error", # enable stale checks (raise on stale)
33
+ tracked_globs: ["lib/**/*.rb"] # for 'all_files' staleness: flag new/missing files
34
+ )
35
+
36
+ # List all files with coverage summary
37
+ files = model.all_files
38
+ # Per-file queries
39
+ summary = model.summary_for("lib/foo.rb")
40
+ uncovered = model.uncovered_for("lib/foo.rb")
41
+ detailed = model.detailed_for("lib/foo.rb")
42
+ raw = model.raw_for("lib/foo.rb")
43
+ ```
44
+
45
+ ## Method Reference
46
+
47
+ ### `all_files(sort_order: :descending)`
48
+
49
+ Returns coverage summary for all files in the resultset.
50
+
51
+ **Parameters:**
52
+ - `sort_order` (Symbol, optional): `:descending` (default) or `:ascending` by coverage percentage
53
+
54
+ **Returns:** `Array<Hash>` - See [all_files return type](#all_files)
55
+
56
+ **Example:**
57
+ ```ruby
58
+ files = model.all_files
59
+ # => [ { 'file' => '/abs/path/lib/foo.rb', 'covered' => 12, 'total' => 14, 'percentage' => 85.71, 'stale' => false }, ... ]
60
+
61
+ # Get worst coverage first
62
+ worst_files = model.all_files(sort_order: :ascending).first(10)
63
+ ```
64
+
65
+ ### `summary_for(path)`
66
+
67
+ Returns coverage summary for a specific file.
68
+
69
+ **Parameters:**
70
+ - `path` (String): File path (absolute, relative to root, or basename)
71
+
72
+ **Returns:** `Hash` - See [summary_for return type](#summary_for)
73
+
74
+ **Raises:** `CovLoupe::FileError` if file not in coverage data
75
+
76
+ **Example:**
77
+ ```ruby
78
+ summary = model.summary_for("lib/foo.rb")
79
+ # => { 'file' => '/abs/.../lib/foo.rb', 'summary' => {'covered'=>12, 'total'=>14, 'percentage'=>85.71}, 'stale' => false }
80
+ ```
81
+
82
+ ### `uncovered_for(path)`
83
+
84
+ Returns list of uncovered line numbers for a specific file.
85
+
86
+ **Parameters:**
87
+ - `path` (String): File path (absolute, relative to root, or basename)
88
+
89
+ **Returns:** `Hash` - See [uncovered_for return type](#uncovered_for)
90
+
91
+ **Raises:** `CovLoupe::FileError` if file not in coverage data
92
+
93
+ **Example:**
94
+ ```ruby
95
+ uncovered = model.uncovered_for("lib/foo.rb")
96
+ # => { 'file' => '/abs/.../lib/foo.rb', 'uncovered' => [5, 9, 12], 'summary' => { ... }, 'stale' => false }
97
+ ```
98
+
99
+ ### `detailed_for(path)`
100
+
101
+ Returns per-line coverage details with hit counts.
102
+
103
+ **Parameters:**
104
+ - `path` (String): File path (absolute, relative to root, or basename)
105
+
106
+ **Returns:** `Hash` - See [detailed_for return type](#detailed_for)
107
+
108
+ **Raises:** `CovLoupe::FileError` if file not in coverage data
109
+
110
+ **Example:**
111
+ ```ruby
112
+ detailed = model.detailed_for("lib/foo.rb")
113
+ # => { 'file' => '/abs/.../lib/foo.rb', 'lines' => [{'line' => 1, 'hits' => 1, 'covered' => true}, ...], 'summary' => { ... }, 'stale' => false }
114
+ ```
115
+
116
+ ### `raw_for(path)`
117
+
118
+ Returns raw SimpleCov lines array for a specific file.
119
+
120
+ **Parameters:**
121
+ - `path` (String): File path (absolute, relative to root, or basename)
122
+
123
+ **Returns:** `Hash` - See [raw_for return type](#raw_for)
124
+
125
+ **Raises:** `CovLoupe::FileError` if file not in coverage data
126
+
127
+ **Example:**
128
+ ```ruby
129
+ raw = model.raw_for("lib/foo.rb")
130
+ # => { 'file' => '/abs/.../lib/foo.rb', 'lines' => [nil, 1, 0, 3, ...], 'stale' => false }
131
+ ```
132
+
133
+ ### `format_table(rows = nil, sort_order: :descending)`
134
+
135
+ Generates formatted ASCII table string.
136
+
137
+ **Parameters:**
138
+ - `rows` (Array<Hash>, optional): Custom row data; defaults to `all_files`
139
+ - `sort_order` (Symbol, optional): `:descending` (default) or `:ascending`
140
+
141
+ **Returns:** `String` - Formatted table with Unicode borders
142
+
143
+ **Example:**
144
+ ```ruby
145
+ # Default: all files
146
+ table = model.format_table
147
+ puts table
148
+
149
+ # Custom rows
150
+ lib_files = model.all_files.select { |f| f['file'].include?('/lib/') }
151
+ lib_table = model.format_table(lib_files, sort_order: :descending)
152
+ puts lib_table
153
+ ```
154
+
155
+ ### `project_totals(tracked_globs: nil)`
156
+
157
+ Returns aggregated coverage totals across all files.
158
+
159
+ **Parameters:**
160
+ - `tracked_globs` (Array<String> or String, optional): Glob patterns to filter files
161
+
162
+ **Returns:** `Hash` - See [project_totals return type](#project_totals)
163
+
164
+ **Example:**
165
+ ```ruby
166
+ totals = model.project_totals
167
+ # => { 'lines' => { 'total' => 123, 'covered' => 100, 'uncovered' => 23 }, 'percentage' => 81.3, 'files' => { 'total' => 5, 'ok' => 4, 'stale' => 1 } }
168
+
169
+ # Filter to specific directory
170
+ lib_totals = model.project_totals(tracked_globs: 'lib/**/*.rb')
171
+ ```
172
+
173
+ ### `relativize(data)`
174
+
175
+ Converts absolute file paths in coverage data to relative paths from project root.
176
+
177
+ **Parameters:**
178
+ - `data` (Hash or Array<Hash>): Coverage data with absolute file paths
179
+
180
+ **Returns:** `Hash` or `Array<Hash>` - Same structure with relative paths
181
+
182
+ **Example:**
183
+ ```ruby
184
+ summary = model.summary_for('lib/cov_loupe/model.rb')
185
+ # => { 'file' => '/home/user/project/lib/cov_loupe/model.rb', ... }
186
+
187
+ relative_summary = model.relativize(summary)
188
+ # => { 'file' => 'lib/cov_loupe/model.rb', ... }
189
+
190
+ # Works with arrays too
191
+ files = model.all_files
192
+ relative_files = model.relativize(files)
193
+ ```
194
+
195
+ ## Return Types
196
+
197
+ ### `all_files`
198
+
199
+ Returns `Array<Hash>` where each hash contains:
200
+
201
+ ```ruby
202
+ {
203
+ 'file' => String, # Absolute file path
204
+ 'covered' => Integer, # Number of covered lines
205
+ 'total' => Integer, # Total relevant lines
206
+ 'percentage' => Float, # Coverage percentage (0.00-100.00)
207
+ 'stale' => false | String # Staleness indicator: false, 'M', 'T', or 'L'
208
+ }
209
+ ```
210
+
211
+ ### `summary_for`
212
+
213
+ Returns `Hash`:
214
+
215
+ ```ruby
216
+ {
217
+ 'file' => String, # Absolute file path
218
+ 'summary' => {
219
+ 'covered' => Integer, # Number of covered lines
220
+ 'total' => Integer, # Total relevant lines
221
+ 'percentage' => Float # Coverage percentage (0.00-100.00)
222
+ }
223
+ }
224
+ ```
225
+
226
+ ### `uncovered_for`
227
+
228
+ Returns `Hash`:
229
+
230
+ ```ruby
231
+ {
232
+ 'file' => String, # Absolute file path
233
+ 'uncovered' => Array<Integer>, # Line numbers that are not covered
234
+ 'summary' => {
235
+ 'covered' => Integer,
236
+ 'total' => Integer,
237
+ 'percentage' => Float
238
+ }
239
+ }
240
+ ```
241
+
242
+ ### `detailed_for`
243
+
244
+ Returns `Hash`:
245
+
246
+ ```ruby
247
+ {
248
+ 'file' => String, # Absolute file path
249
+ 'lines' => Array<Hash>, # Per-line coverage details
250
+ 'summary' => {
251
+ 'covered' => Integer,
252
+ 'total' => Integer,
253
+ 'percentage' => Float
254
+ }
255
+ }
256
+ ```
257
+
258
+ Each element in `lines` array:
259
+ ```ruby
260
+ {
261
+ 'line' => Integer, # Line number (1-indexed)
262
+ 'hits' => Integer, # Execution count (0 means not covered)
263
+ 'covered' => Boolean # true if hits > 0
264
+ }
265
+ ```
266
+
267
+ ### `raw_for`
268
+
269
+ Returns `Hash`:
270
+
271
+ ```ruby
272
+ {
273
+ 'file' => String, # Absolute file path
274
+ 'lines' => Array<Integer | nil> # SimpleCov lines array (nil = irrelevant, 0 = uncovered, >0 = hit count)
275
+ }
276
+ ```
277
+
278
+ ### `project_totals`
279
+
280
+ Returns `Hash`:
281
+
282
+ ```ruby
283
+ {
284
+ 'lines' => {
285
+ 'total' => Integer, # Total relevant lines across all files
286
+ 'covered' => Integer, # Total covered lines
287
+ 'uncovered' => Integer # Total uncovered lines
288
+ },
289
+ 'percentage' => Float, # Overall coverage percentage
290
+ 'files' => {
291
+ 'total' => Integer, # Total number of files
292
+ 'ok' => Integer, # Files with fresh coverage
293
+ 'stale' => Integer # Files with stale coverage
294
+ }
295
+ }
296
+ ```
297
+
298
+ ## Error Handling
299
+
300
+ ### Exception Types
301
+
302
+ The library raises these custom exceptions:
303
+
304
+ - **`CovLoupe::ResultsetNotFoundError`** - Coverage data file not found
305
+ - **`CovLoupe::FileError`** - Requested file not in coverage data
306
+ - **`CovLoupe::CoverageDataStaleError`** - Coverage data is stale (only when `staleness: 'error'`)
307
+ - **`CovLoupe::CoverageDataError`** - Invalid coverage data format or structure
308
+
309
+ All exceptions inherit from `CovLoupe::Error`.
310
+
311
+ ### Basic Error Handling
312
+
313
+ ```ruby
314
+ require "cov_loupe"
315
+
316
+ begin
317
+ model = CovLoupe::CoverageModel.new
318
+ summary = model.summary_for("lib/foo.rb")
319
+ puts "Coverage: #{summary['summary']['percentage']}%"
320
+ rescue CovLoupe::FileError => e
321
+ puts "File not in coverage data: #{e.message}"
322
+ rescue CovLoupe::ResultsetNotFoundError => e
323
+ puts "Coverage data not found: #{e.message}"
324
+ puts "Run your tests first: bundle exec rspec"
325
+ rescue CovLoupe::Error => e
326
+ puts "Coverage error: #{e.message}"
327
+ end
328
+ ```
329
+
330
+ ### Handling Stale Coverage
331
+
332
+ ```ruby
333
+ # Option 1: Check staleness without raising
334
+ model = CovLoupe::CoverageModel.new(staleness: "off")
335
+ files = model.all_files
336
+
337
+ stale_files = files.select { |f| f['stale'] }
338
+ if stale_files.any?
339
+ puts "Warning: #{stale_files.length} files have stale coverage"
340
+ stale_files.each do |f|
341
+ puts " #{f['file']}: #{f['stale']}"
342
+ end
343
+ end
344
+
345
+ # Option 2: Raise on staleness
346
+ begin
347
+ model = CovLoupe::CoverageModel.new(staleness: "error")
348
+ files = model.all_files
349
+ rescue CovLoupe::CoverageDataStaleError => e
350
+ puts "Stale coverage detected: #{e.message}"
351
+ puts "Re-run tests: bundle exec rspec"
352
+ exit 1
353
+ end
354
+ ```
355
+
356
+ ### Graceful Degradation
357
+
358
+ ```ruby
359
+ # Try multiple file paths
360
+ def find_coverage(model, possible_paths)
361
+ possible_paths.each do |path|
362
+ begin
363
+ return model.summary_for(path)
364
+ rescue CovLoupe::FileError
365
+ next
366
+ end
367
+ end
368
+ nil
369
+ end
370
+
371
+ summary = find_coverage(model, [
372
+ "lib/services/auth_service.rb",
373
+ "app/services/auth_service.rb",
374
+ "services/auth_service.rb"
375
+ ])
376
+
377
+ if summary
378
+ puts "Coverage: #{summary['summary']['percentage']}%"
379
+ else
380
+ puts "File not found in coverage data"
381
+ end
382
+ ```
383
+
384
+ ## Advanced Recipes
385
+
386
+ ### Batch File Analysis
387
+
388
+ ```ruby
389
+ require "cov_loupe"
390
+
391
+ model = CovLoupe::CoverageModel.new
392
+
393
+ # Analyze multiple files efficiently
394
+ files_to_check = [
395
+ "lib/auth_service.rb",
396
+ "lib/payment_processor.rb",
397
+ "lib/user_manager.rb"
398
+ ]
399
+
400
+ results = files_to_check.map do |path|
401
+ begin
402
+ summary = model.summary_for(path)
403
+ {
404
+ file: path,
405
+ coverage: summary['summary']['percentage'],
406
+ status: summary['summary']['percentage'] >= 80 ? :ok : :low
407
+ }
408
+ rescue CovLoupe::FileError
409
+ {
410
+ file: path,
411
+ coverage: nil,
412
+ status: :missing
413
+ }
414
+ end
415
+ end
416
+
417
+ # Report
418
+ results.each do |r|
419
+ status_icon = { ok: '✓', low: '⚠', missing: '✗' }[r[:status]]
420
+ puts "#{status_icon} #{r[:file]}: #{r[:coverage] || 'N/A'}%"
421
+ end
422
+ ```
423
+
424
+ ### Coverage Threshold Validation
425
+
426
+ ```ruby
427
+ require "cov_loupe"
428
+
429
+ class CoverageValidator
430
+ THRESHOLDS = {
431
+ 'lib/' => 90.0, # Core library needs 90%+
432
+ 'app/' => 80.0, # Application code needs 80%+
433
+ 'spec/' => 70.0, # Test helpers need 70%+
434
+ }
435
+
436
+ def initialize(model)
437
+ @model = model
438
+ end
439
+
440
+ def validate!
441
+ files = @model.all_files
442
+ failures = []
443
+
444
+ files.each do |file|
445
+ threshold = threshold_for(file['file'])
446
+ next unless threshold
447
+
448
+ if file['percentage'] < threshold
449
+ failures << {
450
+ file: file['file'],
451
+ actual: file['percentage'],
452
+ required: threshold,
453
+ gap: threshold - file['percentage']
454
+ }
455
+ end
456
+ end
457
+
458
+ if failures.any?
459
+ puts "❌ #{failures.length} files below coverage threshold:"
460
+ failures.sort_by { |f| -f[:gap] }.each do |f|
461
+ puts " #{f[:file]}: #{f[:actual]}% (need #{f[:required]}%)"
462
+ end
463
+ exit 1
464
+ else
465
+ puts "✓ All files meet coverage thresholds"
466
+ end
467
+ end
468
+
469
+ private
470
+
471
+ def threshold_for(path)
472
+ THRESHOLDS.each do |prefix, threshold|
473
+ return threshold if path.include?(prefix)
474
+ end
475
+ nil
476
+ end
477
+ end
478
+
479
+ model = CovLoupe::CoverageModel.new
480
+ validator = CoverageValidator.new(model)
481
+ validator.validate!
482
+ ```
483
+
484
+ ### Directory-Level Aggregation
485
+
486
+ ```ruby
487
+ require "cov_loupe"
488
+
489
+ model = CovLoupe::CoverageModel.new
490
+
491
+ # Calculate coverage by directory using the totals API
492
+ patterns = %w[lib/cov_loupe/tools/**/*.rb lib/cov_loupe/commands/**/*.rb lib/cov_loupe/presenters/**/*.rb]
493
+
494
+ directory_stats = patterns.map do |pattern|
495
+ totals = model.project_totals(tracked_globs: pattern)
496
+
497
+ {
498
+ directory: pattern,
499
+ files: totals['files']['total'],
500
+ coverage: totals['percentage'].round(2),
501
+ covered: totals['lines']['covered'],
502
+ total: totals['lines']['total']
503
+ }
504
+ end
505
+
506
+ # Display sorted by coverage
507
+ directory_stats.sort_by { |s| s[:coverage] }.each do |stat|
508
+ puts "#{stat[:directory]}: #{stat[:coverage]}% (#{stat[:files]} files)"
509
+ end
510
+ ```
511
+
512
+ ### Coverage Delta Tracking
513
+
514
+ ```ruby
515
+ require "cov_loupe"
516
+ require "json"
517
+
518
+ class CoverageDeltaTracker
519
+ def initialize(baseline_path: "coverage_baseline.json")
520
+ @baseline_path = baseline_path
521
+ @model = CovLoupe::CoverageModel.new
522
+ end
523
+
524
+ def save_baseline
525
+ current = @model.all_files
526
+ File.write(@baseline_path, JSON.pretty_generate(current))
527
+ puts "Saved coverage baseline (#{current.length} files)"
528
+ end
529
+
530
+ def compare
531
+ unless File.exist?(@baseline_path)
532
+ puts "No baseline found. Run save_baseline first."
533
+ return
534
+ end
535
+
536
+ baseline = JSON.parse(File.read(@baseline_path))
537
+ current = @model.all_files
538
+
539
+ improved = []
540
+ regressed = []
541
+
542
+ current.each do |file|
543
+ baseline_file = baseline.find { |f| f['file'] == file['file'] }
544
+ next unless baseline_file
545
+
546
+ delta = file['percentage'] - baseline_file['percentage']
547
+
548
+ if delta > 0.1
549
+ improved << {
550
+ file: file['file'],
551
+ before: baseline_file['percentage'],
552
+ after: file['percentage'],
553
+ delta: delta
554
+ }
555
+ elsif delta < -0.1
556
+ regressed << {
557
+ file: file['file'],
558
+ before: baseline_file['percentage'],
559
+ after: file['percentage'],
560
+ delta: delta
561
+ }
562
+ end
563
+ end
564
+
565
+ if improved.any?
566
+ puts "\n✓ Coverage Improvements:"
567
+ improved.sort_by { |f| -f[:delta] }.each do |f|
568
+ puts " #{f[:file]}: #{f[:before]}% → #{f[:after]}% (+#{f[:delta].round(2)}%)"
569
+ end
570
+ end
571
+
572
+ if regressed.any?
573
+ puts "\n⚠ Coverage Regressions:"
574
+ regressed.sort_by { |f| f[:delta] }.each do |f|
575
+ puts " #{f[:file]}: #{f[:before]}% → #{f[:after]}% (#{f[:delta].round(2)}%)"
576
+ end
577
+ end
578
+
579
+ if improved.empty? && regressed.empty?
580
+ puts "No significant coverage changes"
581
+ end
582
+ end
583
+ end
584
+
585
+ # Usage
586
+ tracker = CoverageDeltaTracker.new
587
+ tracker.save_baseline # Run before making changes
588
+ # ... make code changes and re-run tests ...
589
+ tracker.compare # See what changed
590
+ ```
591
+
592
+ ### Custom Reporting
593
+
594
+ ```ruby
595
+ require "cov_loupe"
596
+
597
+ class CoverageReporter
598
+ def initialize(model)
599
+ @model = model
600
+ end
601
+
602
+ def generate_markdown_report(output_path)
603
+ files = @model.all_files
604
+ totals = @model.project_totals
605
+
606
+ File.open(output_path, 'w') do |f|
607
+ f.puts "# Coverage Report"
608
+ f.puts
609
+ f.puts "Generated: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
610
+ f.puts
611
+
612
+ # Overall stats
613
+ overall_percentage = totals['percentage']
614
+ total_lines = totals['lines']['total']
615
+ covered_lines = totals['lines']['covered']
616
+ total_files = totals['files']['total']
617
+
618
+ f.puts "## Overall Coverage: #{overall_percentage}%"
619
+ f.puts
620
+ f.puts "- Total Files: #{total_files}"
621
+ f.puts "- Total Lines: #{total_lines}"
622
+ f.puts "- Covered Lines: #{covered_lines}"
623
+ f.puts
624
+
625
+ # Files below threshold
626
+ threshold = 80.0
627
+ low_coverage = files.select { |file| file['percentage'] < threshold }
628
+
629
+ if low_coverage.any?
630
+ f.puts "## Files Below #{threshold}% Coverage"
631
+ f.puts
632
+ f.puts "| File | Coverage | Missing Lines |"
633
+ f.puts "|------|----------|---------------|"
634
+
635
+ low_coverage.sort_by { |file| file['percentage'] }.each do |file|
636
+ uncovered = @model.uncovered_for(file['file'])
637
+ missing_count = uncovered['uncovered'].length
638
+ f.puts "| #{file['file']} | #{file['percentage']}% | #{missing_count} |"
639
+ end
640
+ f.puts
641
+ end
642
+
643
+ # Top performers
644
+ f.puts "## Top 10 Best Covered Files"
645
+ f.puts
646
+ f.puts "| File | Coverage |"
647
+ f.puts "|------|----------|"
648
+
649
+ files.sort_by { |file| -file['percentage'] }.take(10).each do |file|
650
+ f.puts "| #{file['file']} | #{file['percentage']}% |"
651
+ end
652
+ end
653
+
654
+ puts "Report saved to #{output_path}"
655
+ end
656
+ end
657
+
658
+ model = CovLoupe::CoverageModel.new
659
+ reporter = CoverageReporter.new(model)
660
+ reporter.generate_markdown_report("coverage_report.md")
661
+ ```
662
+
663
+ ## Staleness Detection
664
+
665
+ The `all_files` method returns a `'stale'` field for each file with one of these values:
666
+
667
+ - `false` - Coverage data is current
668
+ - `'M'` - **Missing**: File no longer exists on disk
669
+ - `'T'` - **Timestamp**: File modified more recently than coverage data
670
+ - `'L'` - **Length**: Source file line count differs from coverage data
671
+
672
+ **Note:** Per-file methods (`summary_for`, `uncovered_for`, `detailed_for`, `raw_for`) do not include staleness information in their return values. To check staleness for individual files, use `all_files` and filter the results.
673
+
674
+ When `staleness: 'error'` mode is enabled in `CoverageModel.new`, the model will raise `CovLoupe::CoverageDataStaleError` exceptions when stale files are detected during method calls.
675
+
676
+ ## API Stability
677
+
678
+ Consider the following public and stable under SemVer:
679
+ - `CovLoupe::CoverageModel.new(root:, resultset:, staleness: 'off', tracked_globs: nil)`
680
+ - `#raw_for(path)`, `#summary_for(path)`, `#uncovered_for(path)`, `#detailed_for(path)`, `#all_files(sort_order:)`, `#format_table(rows: nil, sort_order:, check_stale:, tracked_globs:)`
681
+ - Return shapes shown in the [Return Types](#return-types) section
682
+ - Exception types documented in [Error Handling](#error-handling)
683
+
684
+ **Note:**
685
+ - CLI (`CovLoupe.run(argv)`) and MCP tools remain stable but are separate surfaces
686
+ - Internal helpers under `CovLoupe::CovUtil` may change; prefer `CoverageModel` unless you need low-level access
687
+
688
+ ## Related Documentation
689
+
690
+ - [Examples](EXAMPLES.md) - Practical cookbook-style examples
691
+ - [CLI Usage](CLI_USAGE.md) - Command-line interface reference
692
+ - [Error Handling](ERROR_HANDLING.md) - Detailed error handling documentation
693
+ - [MCP Integration](MCP_INTEGRATION.md) - AI assistant integration