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,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe do
6
+ describe CovLoupe::ConfigurationError do
7
+ describe '#user_friendly_message' do
8
+ it 'prefixes message with "Configuration error:"' do
9
+ error = described_class.new('Invalid option value')
10
+
11
+ expect(error.user_friendly_message).to eq('Configuration error: Invalid option value')
12
+ end
13
+
14
+ it 'handles empty message' do
15
+ error = described_class.new('')
16
+
17
+ expect(error.user_friendly_message).to eq('Configuration error: ')
18
+ end
19
+
20
+ it 'handles nil message' do
21
+ # When nil is passed to StandardError, it uses the class name as the message
22
+ error = described_class.new(nil)
23
+
24
+ expect(error.user_friendly_message).to eq('Configuration error: CovLoupe::ConfigurationError')
25
+ end
26
+ end
27
+ end
28
+
29
+
30
+ describe CovLoupe::ResultsetNotFoundError do
31
+ describe '#user_friendly_message' do
32
+ it 'includes helpful tips in CLI mode' do
33
+ # Create a CLI context (not MCP mode)
34
+ error_handler = CovLoupe::ErrorHandlerFactory.for_cli
35
+ context = CovLoupe.create_context(error_handler: error_handler, mode: :cli)
36
+ CovLoupe.with_context(context) do
37
+ error = described_class.new('Coverage data not found')
38
+ message = error.user_friendly_message
39
+
40
+ expect(message).to include(
41
+ 'File error: Coverage data not found',
42
+ 'Try one of the following:',
43
+ 'cd to a directory containing coverage/.resultset.json',
44
+ 'Specify a resultset: cov-loupe -r PATH',
45
+ 'Use -h for help: cov-loupe -h'
46
+ )
47
+ end
48
+ end
49
+
50
+ it 'does not include helpful tips in MCP mode' do
51
+ # Create an MCP context
52
+ error_handler = CovLoupe::ErrorHandlerFactory.for_mcp_server
53
+ context = CovLoupe.create_context(error_handler: error_handler, mode: :mcp)
54
+ CovLoupe.with_context(context) do
55
+ error = described_class.new('Coverage data not found')
56
+ message = error.user_friendly_message
57
+
58
+ expect(message).to eq('File error: Coverage data not found')
59
+ expect(message).not_to include('Try one of the following:')
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ describe CovLoupe::CoverageDataStaleError do
66
+ describe 'time formatting edge cases' do
67
+ it 'handles invalid epoch seconds gracefully in rescue path' do
68
+ # Create an object that responds to to_i but breaks Time.at
69
+ bad_timestamp = Object.new
70
+ def bad_timestamp.to_i
71
+ raise ArgumentError, "Can't convert"
72
+ end
73
+
74
+ error = described_class.new(
75
+ 'Test error',
76
+ nil,
77
+ file_path: 'test.rb',
78
+ file_mtime: Time.at(1000),
79
+ cov_timestamp: bad_timestamp,
80
+ src_len: 10,
81
+ cov_len: 8
82
+ )
83
+
84
+ message = error.user_friendly_message
85
+ expect(message).to include('Coverage data stale')
86
+ expect(message).to include('Test error')
87
+ end
88
+
89
+ it 'handles time that breaks Time.parse but has valid to_s' do
90
+ # Create an object that can't be parsed but has valid to_s
91
+ bad_time = Object.new
92
+ def bad_time.to_s
93
+ 'unparseable_time_string'
94
+ end
95
+
96
+ error = described_class.new(
97
+ 'Test error',
98
+ nil,
99
+ file_path: 'test.rb',
100
+ file_mtime: bad_time,
101
+ cov_timestamp: 1000,
102
+ src_len: 10,
103
+ cov_len: 8
104
+ )
105
+
106
+ message = error.user_friendly_message
107
+ expect(message).to include('Coverage data stale')
108
+ expect(message).to include('Test error')
109
+ # Should fallback to string representation
110
+ expect(message).to include('unparseable_time_string')
111
+ end
112
+
113
+ it 'handles delta calculation with invalid values in rescue path' do
114
+ # Create objects that break arithmetic
115
+ bad_time = Object.new
116
+ def bad_time.to_i
117
+ raise ArgumentError, "Can't convert"
118
+ end
119
+
120
+ bad_timestamp = Object.new
121
+ def bad_timestamp.to_i
122
+ raise ArgumentError, "Can't convert"
123
+ end
124
+
125
+ error = described_class.new(
126
+ 'Test error',
127
+ nil,
128
+ file_path: 'test.rb',
129
+ file_mtime: bad_time,
130
+ cov_timestamp: bad_timestamp,
131
+ src_len: 10,
132
+ cov_len: 8
133
+ )
134
+
135
+ message = error.user_friendly_message
136
+ expect(message).to include('Coverage data stale')
137
+ # Delta line should not appear when calculation fails
138
+ expect(message).not_to match(/Delta\s+- file is/)
139
+ end
140
+ end
141
+
142
+ describe 'default message generation' do
143
+ it 'uses default message when message is nil with file_path' do
144
+ error = described_class.new(
145
+ nil, # No message provided - triggers default_message
146
+ nil,
147
+ file_path: 'test.rb',
148
+ file_mtime: Time.at(2000),
149
+ cov_timestamp: 1000
150
+ )
151
+
152
+ message = error.user_friendly_message
153
+ # default_message returns "Coverage data appears stale for test.rb"
154
+ expect(message).to include('Coverage data appears stale for test.rb')
155
+ # File path should appear in the details section
156
+ expect(message).to match(/File\s+-/)
157
+ end
158
+
159
+ it 'uses generic default message when file_path is nil' do
160
+ # This tests the fallback path when file_path is nil: fp = file_path || 'file'
161
+ error = described_class.new(
162
+ nil, # No message - triggers default_message
163
+ nil,
164
+ file_path: nil, # No file path - triggers 'file' fallback
165
+ file_mtime: Time.at(2000),
166
+ cov_timestamp: 1000
167
+ )
168
+
169
+ message = error.user_friendly_message
170
+ # When file_path is nil, default_message returns "Coverage data appears stale for file"
171
+ expect(message).to include('Coverage data appears stale for file')
172
+ end
173
+ end
174
+ end
175
+
176
+ describe CovLoupe::CoverageDataProjectStaleError do
177
+ describe 'default message generation' do
178
+ # These tests exercise the private default_message method
179
+ it 'includes project stale info when message is nil' do
180
+ error = described_class.new(
181
+ nil, # StandardError sets message to class name when nil
182
+ nil,
183
+ cov_timestamp: 1000,
184
+ newer_files: ['file1.rb', 'file2.rb']
185
+ )
186
+
187
+ message = error.user_friendly_message
188
+ # user_friendly_message prefixes with "Coverage data stale (project):"
189
+ expect(message).to include('Coverage data stale (project)')
190
+ expect(message).to include('Newer files')
191
+ end
192
+
193
+ it 'exercises default_message directly via send' do
194
+ # Directly test the private default_message method for coverage
195
+ # This is necessary because user_friendly_message uses `message || default_message`
196
+ # and StandardError sets message to class name when initialized with nil
197
+ error = described_class.new(
198
+ 'explicit message',
199
+ nil,
200
+ cov_timestamp: 1000
201
+ )
202
+
203
+ # Call the private default_message method directly
204
+ result = error.send(:default_message)
205
+ expect(result).to eq('Coverage data appears stale for project')
206
+ end
207
+ end
208
+
209
+ describe 'large file list truncation' do
210
+ it 'shows all files when there are 10 or fewer deleted files' do
211
+ deleted_files = (1..10).map { |i| "deleted_file_#{i}.rb" }
212
+ error = described_class.new(
213
+ 'Test error',
214
+ nil,
215
+ cov_timestamp: 1000,
216
+ deleted_files: deleted_files
217
+ )
218
+
219
+ message = error.user_friendly_message
220
+ expect(message).to include('Coverage-only files (deleted or moved in project, 10):')
221
+ deleted_files.each do |file|
222
+ expect(message).to include(" - #{file}")
223
+ end
224
+ expect(message).not_to include('...')
225
+ end
226
+
227
+ it 'truncates and shows ellipsis when there are more than 10 deleted files' do
228
+ deleted_files = (1..15).map { |i| "deleted_file_#{i}.rb" }
229
+ error = described_class.new(
230
+ 'Test error',
231
+ nil,
232
+ cov_timestamp: 1000,
233
+ deleted_files: deleted_files
234
+ )
235
+
236
+ message = error.user_friendly_message
237
+ expect(message).to include('Coverage-only files (deleted or moved in project, 15):')
238
+ # Should show first 10 files
239
+ deleted_files[0..9].each do |file|
240
+ expect(message).to include(" - #{file}")
241
+ end
242
+ # Should not show files beyond 10
243
+ deleted_files[10..14].each do |file|
244
+ expect(message).not_to include(" - #{file}")
245
+ end
246
+ # Should show ellipsis
247
+ expect(message).to include('...')
248
+ end
249
+
250
+ it 'shows all files when there are 10 or fewer missing files' do
251
+ missing_files = (1..10).map { |i| "missing_file_#{i}.rb" }
252
+ error = described_class.new(
253
+ 'Test error',
254
+ nil,
255
+ cov_timestamp: 1000,
256
+ missing_files: missing_files
257
+ )
258
+
259
+ message = error.user_friendly_message
260
+ expect(message).to include('Missing files (new in project, not in coverage, 10):')
261
+ missing_files.each do |file|
262
+ expect(message).to include(" - #{file}")
263
+ end
264
+ expect(message).not_to include('...')
265
+ end
266
+
267
+ it 'truncates and shows ellipsis when there are more than 10 missing files' do
268
+ missing_files = (1..12).map { |i| "missing_file_#{i}.rb" }
269
+ error = described_class.new(
270
+ 'Test error',
271
+ nil,
272
+ cov_timestamp: 1000,
273
+ missing_files: missing_files
274
+ )
275
+
276
+ message = error.user_friendly_message
277
+ expect(message).to include('Missing files (new in project, not in coverage, 12):')
278
+ # Should show first 10 files
279
+ missing_files[0..9].each do |file|
280
+ expect(message).to include(" - #{file}")
281
+ end
282
+ # Should not show files beyond 10
283
+ expect(message).not_to include(" - #{missing_files[11]}")
284
+ # Should show ellipsis
285
+ expect(message).to include('...')
286
+ end
287
+
288
+ it 'truncates and shows ellipsis when there are more than 10 newer files' do
289
+ newer_files = (1..20).map { |i| "newer_file_#{i}.rb" }
290
+ error = described_class.new(
291
+ 'Test error',
292
+ nil,
293
+ cov_timestamp: 1000,
294
+ newer_files: newer_files
295
+ )
296
+
297
+ message = error.user_friendly_message
298
+ expect(message).to include('Newer files (20):')
299
+ # Should show first 10 files
300
+ newer_files[0..9].each do |file|
301
+ expect(message).to include(" - #{file}")
302
+ end
303
+ # Should not show files beyond 10
304
+ newer_files[10..19].each do |file|
305
+ expect(message).not_to include(" - #{file}")
306
+ end
307
+ # Should show ellipsis
308
+ expect(message).to include('...')
309
+ end
310
+ end
311
+ end
312
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::CoverageDataStaleError do
6
+ it 'formats a detailed, user-friendly message with UTC/local, delta, and resultset' do
7
+ file_time = Time.at(TEST_FILE_TIMESTAMP) # 1970-01-01T00:16:40Z
8
+ cov_epoch = VERY_OLD_TIMESTAMP # 1970-01-01T00:00:00Z
9
+ err = described_class.new(
10
+ 'Coverage data appears stale for foo.rb',
11
+ nil,
12
+ file_path: 'foo.rb',
13
+ file_mtime: file_time,
14
+ cov_timestamp: cov_epoch,
15
+ src_len: 10,
16
+ cov_len: 8,
17
+ resultset_path: '/path/to/coverage/.resultset.json'
18
+ )
19
+
20
+ msg = err.user_friendly_message
21
+
22
+ expect(msg).to include('Coverage data stale: Coverage data appears stale for foo.rb')
23
+ expect(msg).to match(/File\s*-\s*time:\s*1970-01-01T00:16:40Z/)
24
+ expect(msg).to include('(local ') # do not assert exact local tz
25
+ expect(msg).to match(/Coverage\s*-\s*time:\s*1970-01-01T00:00:00Z/)
26
+ expect(msg).to match(/lines:\s*10/)
27
+ expect(msg).to match(/lines:\s*8/)
28
+ expect(msg).to match(/Delta\s*- file is \+1000s newer than coverage/)
29
+ expect(msg).to include('Resultset - /path/to/coverage/.resultset.json')
30
+ end
31
+
32
+ it 'handles missing timestamps gracefully' do
33
+ err = described_class.new(
34
+ 'Coverage data appears stale for bar.rb',
35
+ nil,
36
+ file_path: 'bar.rb',
37
+ file_mtime: nil,
38
+ cov_timestamp: nil,
39
+ src_len: 1,
40
+ cov_len: 0,
41
+ resultset_path: nil
42
+ )
43
+ msg = err.user_friendly_message
44
+ expect(msg).to include('Coverage data stale: Coverage data appears stale for bar.rb')
45
+ expect(msg).to match(/File\s*-\s*time:\s*not found.*lines: 1/m)
46
+ expect(msg).to match(/Coverage\s*-\s*time:\s*not found.*lines: 0/m)
47
+ expect(msg).not_to include('Delta')
48
+ end
49
+
50
+ it 'uses default message when message is nil' do
51
+ err = described_class.new(
52
+ nil,
53
+ nil,
54
+ file_path: 'lib/example.rb',
55
+ file_mtime: Time.now,
56
+ cov_timestamp: Time.now.to_i - 1000,
57
+ src_len: 10,
58
+ cov_len: 8,
59
+ resultset_path: '/coverage/.resultset.json'
60
+ )
61
+
62
+ msg = err.user_friendly_message
63
+ expect(msg).to include('Coverage data stale:')
64
+ expect(msg).to include('Coverage data appears stale for lib/example.rb')
65
+ end
66
+
67
+ it 'uses "file" in default message when file_path is also nil' do
68
+ err = described_class.new(
69
+ nil,
70
+ nil,
71
+ file_path: nil,
72
+ file_mtime: nil,
73
+ cov_timestamp: nil,
74
+ src_len: 0,
75
+ cov_len: 0,
76
+ resultset_path: nil
77
+ )
78
+
79
+ msg = err.user_friendly_message
80
+ expect(msg).to include('Coverage data stale:')
81
+ expect(msg).to include('Coverage data appears stale for file')
82
+ end
83
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative 'shared_examples/file_based_mcp_tools'
5
+
6
+ # Load all the tool classes that will be tested
7
+ require 'cov_loupe/tools/coverage_summary_tool'
8
+ require 'cov_loupe/tools/coverage_raw_tool'
9
+ require 'cov_loupe/tools/uncovered_lines_tool'
10
+ require 'cov_loupe/tools/coverage_detailed_tool'
11
+
12
+ RSpec.describe 'File-based MCP Tools' do
13
+ # Test each file-based tool using the shared example with its specific configuration
14
+ FILE_BASED_TOOL_CONFIGS.each_value do |config|
15
+ describe config[:tool_class] do
16
+ it_behaves_like 'a file-based MCP tool', config
17
+ end
18
+ end
19
+
20
+ # Test that all file-based tools handle the same parameters consistently
21
+ describe 'parameter consistency' do
22
+ let(:server_context) { instance_double('ServerContext').as_null_object }
23
+
24
+ before do
25
+ setup_mcp_response_stub
26
+ end
27
+
28
+ it 'all file-based tools accept the same basic parameters' do
29
+ # Test that all tools can be called with the same parameter signature
30
+ FILE_BASED_TOOL_CONFIGS.each_value do |config|
31
+ model = instance_double(CovLoupe::CoverageModel)
32
+ allow(CovLoupe::CoverageModel).to receive(:new).and_return(model)
33
+ allow(model).to receive(config[:model_method]).and_return(config[:mock_data])
34
+ allow(model).to receive(:relativize) { |payload| payload }
35
+ allow(model).to receive(:staleness_for).and_return(false)
36
+
37
+ expect do
38
+ config[:tool_class].call(
39
+ path: 'lib/example.rb',
40
+ root: '.',
41
+ resultset: 'coverage',
42
+ server_context: server_context
43
+ )
44
+ end.not_to raise_error
45
+ end
46
+ end
47
+
48
+ it 'all file-based tools return JSON resources with consistent structure' do
49
+ FILE_BASED_TOOL_CONFIGS.each_value do |config|
50
+ model = instance_double(CovLoupe::CoverageModel)
51
+ allow(CovLoupe::CoverageModel).to receive(:new).and_return(model)
52
+ allow(model).to receive(config[:model_method]).and_return(config[:mock_data])
53
+ allow(model).to receive(:relativize) { |payload| payload }
54
+ allow(model).to receive(:staleness_for).and_return(false)
55
+
56
+ response = config[:tool_class].call(path: 'lib/foo.rb', server_context: server_context)
57
+
58
+ # All should have the same basic MCP text structure
59
+ expect(response.payload).to be_an(Array)
60
+ expect(response.payload.first['type']).to eq('text')
61
+ expect(response.payload.first).to have_key('text')
62
+
63
+ # All should return valid JSON
64
+ expect { JSON.parse(response.payload.first['text']) }.not_to raise_error
65
+ end
66
+ end
67
+ end
68
+
69
+ # Performance/behavior comparison tests
70
+ describe 'cross-tool consistency' do
71
+ let(:server_context) { instance_double('ServerContext').as_null_object }
72
+
73
+ before do
74
+ setup_mcp_response_stub
75
+ end
76
+
77
+ it 'tools that include summary data return consistent summary format' do
78
+ summary_tools = FILE_BASED_TOOL_CONFIGS.select do |_, config|
79
+ config[:expected_keys].include?('summary')
80
+ end
81
+
82
+ summary_tools.each_value do |config|
83
+ model = instance_double(CovLoupe::CoverageModel)
84
+ allow(CovLoupe::CoverageModel).to receive(:new).and_return(model)
85
+ allow(model).to receive(config[:model_method]).and_return(config[:mock_data])
86
+ allow(model).to receive(:relativize) { |payload| payload }
87
+ allow(model).to receive(:staleness_for).and_return(false)
88
+
89
+ response = config[:tool_class].call(path: 'lib/foo.rb', server_context: server_context)
90
+ data = JSON.parse(response.payload.first['text'])
91
+
92
+ if data.key?('summary')
93
+ expect(data['summary']).to include('covered', 'total', 'percentage')
94
+ expect(data['summary']['percentage']).to be_a(Numeric)
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ puts 'bar line 1 (uncovered)'
4
+ puts 'bar line 2 (uncovered)'
5
+ puts 'bar line 3'
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ puts 'foo line 1'
4
+ puts 'foo line 2 (uncovered)'
5
+
6
+ puts 'foo line 4'
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'cov_loupe/tools/help_tool'
5
+
6
+ RSpec.describe CovLoupe::Tools::HelpTool do
7
+ let(:server_context) { instance_double('ServerContext').as_null_object }
8
+
9
+ before do
10
+ setup_mcp_response_stub
11
+ end
12
+
13
+ it 'returns guidance for each registered tool' do
14
+ response = described_class.call(server_context: server_context)
15
+ expect(response.meta).to be_nil
16
+
17
+ payload = response.payload.first
18
+ expect(payload['type']).to eq('text')
19
+ data = JSON.parse(payload['text'])
20
+ tool_names = data['tools'].map { |entry| entry['tool'] }
21
+
22
+ expect(tool_names).to include('coverage_summary_tool', 'uncovered_lines_tool',
23
+ 'all_files_coverage_tool', 'coverage_totals_tool', 'coverage_table_tool', 'version_tool')
24
+ expect(data['tools']).to all(include('use_when', 'avoid_when', 'inputs'))
25
+ end
26
+ end