simplecov-mcp 1.0.1 → 2.0.1

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 (164) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +98 -50
  3. data/docs/{ARCHITECTURE.md → dev/ARCHITECTURE.md} +11 -10
  4. data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
  5. data/docs/{DEVELOPMENT.md → dev/DEVELOPMENT.md} +2 -1
  6. data/docs/dev/README.md +10 -0
  7. data/docs/dev/RELEASING.md +146 -0
  8. data/docs/{arch-decisions → dev/arch-decisions}/001-x-arch-decision.md +3 -1
  9. data/docs/{arch-decisions → dev/arch-decisions}/002-x-arch-decision.md +7 -5
  10. data/docs/{arch-decisions → dev/arch-decisions}/003-x-arch-decision.md +2 -0
  11. data/docs/{arch-decisions → dev/arch-decisions}/004-x-arch-decision.md +6 -2
  12. data/docs/{arch-decisions → dev/arch-decisions}/005-x-arch-decision.md +4 -2
  13. data/docs/{arch-decisions → dev/arch-decisions}/README.md +3 -3
  14. data/docs/{presentations → dev/presentations}/simplecov-mcp-presentation.md +28 -22
  15. data/docs/fixtures/demo_project/README.md +9 -0
  16. data/docs/{ADVANCED_USAGE.md → user/ADVANCED_USAGE.md} +129 -319
  17. data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
  18. data/docs/user/CLI_USAGE.md +750 -0
  19. data/docs/{ERROR_HANDLING.md → user/ERROR_HANDLING.md} +12 -12
  20. data/docs/user/EXAMPLES.md +588 -0
  21. data/docs/user/INSTALLATION.md +130 -0
  22. data/docs/{LIBRARY_API.md → user/LIBRARY_API.md} +90 -32
  23. data/docs/{MCP_INTEGRATION.md → user/MCP_INTEGRATION.md} +36 -34
  24. data/docs/user/README.md +14 -0
  25. data/docs/{TROUBLESHOOTING.md → user/TROUBLESHOOTING.md} +21 -100
  26. data/docs/user/V2-BREAKING-CHANGES.md +472 -0
  27. data/exe/simplecov-mcp +1 -1
  28. data/lib/simplecov_mcp/{cli_config.rb → app_config.rb} +12 -12
  29. data/lib/simplecov_mcp/app_context.rb +1 -1
  30. data/lib/simplecov_mcp/base_tool.rb +66 -38
  31. data/lib/simplecov_mcp/cli.rb +67 -123
  32. data/lib/simplecov_mcp/commands/base_command.rb +16 -27
  33. data/lib/simplecov_mcp/commands/command_factory.rb +8 -2
  34. data/lib/simplecov_mcp/commands/detailed_command.rb +16 -2
  35. data/lib/simplecov_mcp/commands/list_command.rb +1 -1
  36. data/lib/simplecov_mcp/commands/raw_command.rb +18 -2
  37. data/lib/simplecov_mcp/commands/summary_command.rb +20 -3
  38. data/lib/simplecov_mcp/commands/totals_command.rb +53 -0
  39. data/lib/simplecov_mcp/commands/uncovered_command.rb +24 -5
  40. data/lib/simplecov_mcp/commands/validate_command.rb +60 -0
  41. data/lib/simplecov_mcp/commands/version_command.rb +19 -4
  42. data/lib/simplecov_mcp/config_parser.rb +32 -0
  43. data/lib/simplecov_mcp/constants.rb +3 -3
  44. data/lib/simplecov_mcp/coverage_reporter.rb +31 -0
  45. data/lib/simplecov_mcp/error_handler.rb +81 -40
  46. data/lib/simplecov_mcp/error_handler_factory.rb +2 -2
  47. data/lib/simplecov_mcp/errors.rb +12 -19
  48. data/lib/simplecov_mcp/formatters/source_formatter.rb +23 -19
  49. data/lib/simplecov_mcp/formatters.rb +51 -0
  50. data/lib/simplecov_mcp/mcp_server.rb +9 -7
  51. data/lib/simplecov_mcp/mode_detector.rb +6 -5
  52. data/lib/simplecov_mcp/model.rb +122 -88
  53. data/lib/simplecov_mcp/option_normalizers.rb +39 -18
  54. data/lib/simplecov_mcp/option_parser_builder.rb +85 -72
  55. data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +3 -5
  56. data/lib/simplecov_mcp/option_parsers/error_helper.rb +18 -17
  57. data/lib/simplecov_mcp/path_relativizer.rb +17 -14
  58. data/lib/simplecov_mcp/predicate_evaluator.rb +72 -0
  59. data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +1 -3
  60. data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +1 -3
  61. data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +1 -3
  62. data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +1 -3
  63. data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +1 -3
  64. data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +1 -3
  65. data/lib/simplecov_mcp/presenters/project_totals_presenter.rb +27 -0
  66. data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +14 -18
  67. data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +7 -9
  68. data/lib/simplecov_mcp/resultset_loader.rb +20 -25
  69. data/lib/simplecov_mcp/staleness_checker.rb +50 -46
  70. data/lib/simplecov_mcp/table_formatter.rb +64 -0
  71. data/lib/simplecov_mcp/tools/all_files_coverage_tool.rb +20 -50
  72. data/lib/simplecov_mcp/tools/coverage_detailed_tool.rb +13 -7
  73. data/lib/simplecov_mcp/tools/coverage_raw_tool.rb +12 -7
  74. data/lib/simplecov_mcp/tools/coverage_summary_tool.rb +13 -8
  75. data/lib/simplecov_mcp/tools/coverage_table_tool.rb +20 -60
  76. data/lib/simplecov_mcp/tools/coverage_totals_tool.rb +44 -0
  77. data/lib/simplecov_mcp/tools/help_tool.rb +38 -66
  78. data/lib/simplecov_mcp/tools/uncovered_lines_tool.rb +13 -8
  79. data/lib/simplecov_mcp/tools/validate_tool.rb +72 -0
  80. data/lib/simplecov_mcp/tools/version_tool.rb +7 -14
  81. data/lib/simplecov_mcp/util.rb +18 -12
  82. data/lib/simplecov_mcp/version.rb +1 -1
  83. data/lib/simplecov_mcp.rb +23 -29
  84. data/spec/all_files_coverage_tool_spec.rb +4 -3
  85. data/spec/{cli_config_spec.rb → app_config_spec.rb} +31 -26
  86. data/spec/base_tool_spec.rb +17 -14
  87. data/spec/cli/show_default_report_spec.rb +2 -2
  88. data/spec/cli_enumerated_options_spec.rb +31 -9
  89. data/spec/cli_error_spec.rb +46 -23
  90. data/spec/cli_format_spec.rb +123 -0
  91. data/spec/cli_json_options_spec.rb +50 -0
  92. data/spec/cli_source_spec.rb +11 -63
  93. data/spec/cli_spec.rb +82 -97
  94. data/spec/cli_usage_spec.rb +15 -15
  95. data/spec/commands/base_command_spec.rb +12 -92
  96. data/spec/commands/command_factory_spec.rb +7 -3
  97. data/spec/commands/detailed_command_spec.rb +10 -24
  98. data/spec/commands/list_command_spec.rb +28 -0
  99. data/spec/commands/raw_command_spec.rb +43 -20
  100. data/spec/commands/summary_command_spec.rb +10 -23
  101. data/spec/commands/totals_command_spec.rb +34 -0
  102. data/spec/commands/uncovered_command_spec.rb +29 -23
  103. data/spec/commands/validate_command_spec.rb +213 -0
  104. data/spec/commands/version_command_spec.rb +38 -0
  105. data/spec/constants_spec.rb +3 -3
  106. data/spec/coverage_reporter_spec.rb +102 -0
  107. data/spec/coverage_table_tool_spec.rb +21 -10
  108. data/spec/coverage_totals_tool_spec.rb +37 -0
  109. data/spec/error_handler_spec.rb +120 -4
  110. data/spec/error_mode_spec.rb +18 -22
  111. data/spec/errors_edge_cases_spec.rb +101 -28
  112. data/spec/errors_stale_spec.rb +34 -0
  113. data/spec/file_based_mcp_tools_spec.rb +6 -6
  114. data/spec/fixtures/project1/lib/bar.rb +2 -0
  115. data/spec/fixtures/project1/lib/foo.rb +2 -0
  116. data/spec/help_tool_spec.rb +2 -18
  117. data/spec/integration_spec.rb +103 -161
  118. data/spec/logging_fallback_spec.rb +3 -3
  119. data/spec/mcp_server_integration_spec.rb +1 -1
  120. data/spec/mcp_server_spec.rb +70 -53
  121. data/spec/mode_detector_spec.rb +46 -41
  122. data/spec/model_error_handling_spec.rb +139 -78
  123. data/spec/model_staleness_spec.rb +13 -13
  124. data/spec/option_normalizers_spec.rb +111 -112
  125. data/spec/option_parsers/env_options_parser_spec.rb +25 -37
  126. data/spec/option_parsers/error_helper_spec.rb +56 -56
  127. data/spec/path_relativizer_spec.rb +15 -0
  128. data/spec/presenters/coverage_detailed_presenter_spec.rb +1 -1
  129. data/spec/presenters/coverage_summary_presenter_spec.rb +1 -1
  130. data/spec/presenters/coverage_uncovered_presenter_spec.rb +1 -1
  131. data/spec/presenters/project_coverage_presenter_spec.rb +9 -8
  132. data/spec/presenters/project_totals_presenter_spec.rb +144 -0
  133. data/spec/resolvers/coverage_line_resolver_spec.rb +261 -36
  134. data/spec/resolvers/resultset_path_resolver_spec.rb +13 -8
  135. data/spec/shared_examples/file_based_mcp_tools.rb +23 -18
  136. data/spec/shared_examples/formatted_command_examples.rb +64 -0
  137. data/spec/simple_cov_mcp_module_spec.rb +24 -3
  138. data/spec/simplecov_mcp/formatters/source_formatter_spec.rb +267 -0
  139. data/spec/simplecov_mcp/formatters_spec.rb +76 -0
  140. data/spec/simplecov_mcp/presenters/base_coverage_presenter_spec.rb +79 -0
  141. data/spec/simplecov_mcp_model_spec.rb +97 -47
  142. data/spec/simplecov_mcp_opts_spec.rb +42 -39
  143. data/spec/spec_helper.rb +27 -92
  144. data/spec/staleness_checker_spec.rb +10 -9
  145. data/spec/staleness_more_spec.rb +4 -4
  146. data/spec/support/cli_helpers.rb +22 -0
  147. data/spec/support/control_flow_helpers.rb +20 -0
  148. data/spec/support/fake_mcp.rb +40 -0
  149. data/spec/support/io_helpers.rb +29 -0
  150. data/spec/support/mcp_helpers.rb +35 -0
  151. data/spec/support/mcp_runner.rb +10 -8
  152. data/spec/support/mocking_helpers.rb +30 -0
  153. data/spec/table_format_spec.rb +70 -0
  154. data/spec/tools/validate_tool_spec.rb +132 -0
  155. data/spec/tools_error_handling_spec.rb +34 -48
  156. data/spec/util_spec.rb +5 -4
  157. data/spec/version_spec.rb +7 -7
  158. data/spec/version_tool_spec.rb +20 -22
  159. metadata +90 -23
  160. data/docs/BRANCH_ONLY_COVERAGE.md +0 -81
  161. data/docs/CLI_USAGE.md +0 -637
  162. data/docs/EXAMPLES.md +0 -430
  163. data/docs/INSTALLATION.md +0 -352
  164. data/spec/cli_success_predicate_spec.rb +0 -141
@@ -3,15 +3,17 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  RSpec.describe SimpleCovMcp::CoverageModel do
6
- let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
6
  subject(:model) { described_class.new(root: root) }
8
7
 
8
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
9
+
10
+
9
11
  describe 'initialization error handling' do
10
12
  it 'raises FileError when File.read raises Errno::ENOENT directly' do
11
13
  # Stub find_resultset to return a path, but File.read to raise ENOENT
12
14
  allow(SimpleCovMcp::CovUtil).to receive(:find_resultset)
13
15
  .and_return('/some/path/.resultset.json')
14
- allow(File).to receive(:read).with('/some/path/.resultset.json')
16
+ allow(JSON).to receive(:load_file).with('/some/path/.resultset.json')
15
17
  .and_raise(Errno::ENOENT, 'No such file')
16
18
 
17
19
  expect do
@@ -35,11 +37,11 @@ RSpec.describe SimpleCovMcp::CoverageModel do
35
37
  end
36
38
 
37
39
  describe 'summary_for' do
38
- it 'computes covered/total/pct' do
40
+ it 'computes covered/total/percentage' do
39
41
  data = model.summary_for('lib/foo.rb')
40
42
  expect(data['summary']['total']).to eq(3)
41
43
  expect(data['summary']['covered']).to eq(2)
42
- expect(data['summary']['pct']).to be_within(0.01).of(66.67)
44
+ expect(data['summary']['percentage']).to be_within(0.01).of(66.67)
43
45
  end
44
46
  end
45
47
 
@@ -75,35 +77,46 @@ RSpec.describe SimpleCovMcp::CoverageModel do
75
77
 
76
78
  describe 'staleness_for' do
77
79
  it 'returns the staleness character for a file' do
78
- allow_any_instance_of(SimpleCovMcp::StalenessChecker)
79
- .to receive(:stale_for_file?) do |_, file_abs, _|
80
- if file_abs == File.expand_path('lib/foo.rb', root)
81
- 'T'
82
- else
83
- false
84
- end
80
+ checker = instance_double(SimpleCovMcp::StalenessChecker, off?: false)
81
+ allow(SimpleCovMcp::StalenessChecker).to receive(:new).and_return(checker)
82
+ allow(checker).to receive(:stale_for_file?) do |file_abs, _|
83
+ if file_abs == File.expand_path('lib/foo.rb', root)
84
+ 'T'
85
+ else
86
+ false
85
87
  end
88
+ end
86
89
 
87
90
  expect(model.staleness_for('lib/foo.rb')).to eq('T')
88
- expect(model.staleness_for('lib/bar.rb')).to eq(false)
91
+ expect(model.staleness_for('lib/bar.rb')).to be(false)
89
92
  end
90
93
 
91
94
  it 'returns false when an exception occurs during staleness check' do
92
95
  # Stub the checker to raise an error
93
- allow_any_instance_of(SimpleCovMcp::StalenessChecker).to receive(:stale_for_file?)
96
+ checker = instance_double(SimpleCovMcp::StalenessChecker, off?: false)
97
+ allow(SimpleCovMcp::StalenessChecker).to receive(:new).and_return(checker)
98
+ allow(checker).to receive(:stale_for_file?)
94
99
  .and_raise(StandardError, 'Something went wrong')
95
100
 
96
101
  # The rescue clause should catch the error and return false
97
- expect(model.staleness_for('lib/foo.rb')).to eq(false)
102
+ expect(model.staleness_for('lib/foo.rb')).to be(false)
98
103
  end
99
104
 
100
105
  it 'returns false when coverage data is not found for the file' do
101
106
  # Try to get staleness for a file that doesn't exist in coverage
102
- expect(model.staleness_for('lib/nonexistent.rb')).to eq(false)
107
+ expect(model.staleness_for('lib/nonexistent.rb')).to be(false)
103
108
  end
104
109
  end
105
110
 
106
111
  describe 'all_files' do
112
+ it 'sorts descending (default) by percentage then by file path' do
113
+ files = model.all_files
114
+ # lib/foo.rb has 66.67%, lib/bar.rb has 33.33%
115
+ expect(files.first['file']).to eq(File.expand_path('lib/foo.rb', root))
116
+ expect(files.first['percentage']).to be_within(0.01).of(66.67)
117
+ expect(files.last['file']).to eq(File.expand_path('lib/bar.rb', root))
118
+ end
119
+
107
120
  it 'sorts ascending by percentage then by file path' do
108
121
  files = model.all_files(sort_order: :ascending)
109
122
  expect(files.first['file']).to eq(File.expand_path('lib/bar.rb', root))
@@ -151,6 +164,24 @@ RSpec.describe SimpleCovMcp::CoverageModel do
151
164
  end
152
165
  end
153
166
 
167
+ describe '#project_totals' do
168
+ it 'aggregates coverage totals across all files' do
169
+ totals = model.project_totals
170
+
171
+ expect(totals['lines']).to include('total' => 6, 'covered' => 3, 'uncovered' => 3)
172
+ expect(totals['percentage']).to be_within(0.01).of(50.0)
173
+ expect(totals['files']).to include('total' => 2)
174
+ expect(totals['files']['ok'] + totals['files']['stale']).to eq(totals['files']['total'])
175
+ end
176
+
177
+ it 'respects tracked_globs filtering' do
178
+ totals = model.project_totals(tracked_globs: ['lib/foo.rb'])
179
+
180
+ expect(totals['lines']).to include('total' => 3, 'covered' => 2, 'uncovered' => 1)
181
+ expect(totals['files']).to include('total' => 1)
182
+ end
183
+ end
184
+
154
185
  describe 'resolve method error handling' do
155
186
  it 'raises FileError when coverage_lines is nil after lookup' do
156
187
  # Stub lookup_lines to return nil without raising
@@ -164,16 +195,27 @@ RSpec.describe SimpleCovMcp::CoverageModel do
164
195
  it 'converts Errno::ENOENT to FileNotFoundError during resolve' do
165
196
  # We need to trigger Errno::ENOENT inside the resolve method
166
197
  # Stub the checker's check_file! method to raise Errno::ENOENT
167
- allow_any_instance_of(SimpleCovMcp::StalenessChecker).to receive(:check_file!)
198
+ checker = instance_double(SimpleCovMcp::StalenessChecker, off?: false)
199
+ allow(SimpleCovMcp::StalenessChecker).to receive(:new).and_return(checker)
200
+ allow(checker).to receive(:check_file!)
168
201
  .and_raise(Errno::ENOENT, 'No such file or directory')
169
202
 
170
203
  # Create a model with staleness checking enabled to trigger the check_file! call
171
- stale_model = described_class.new(root: root, staleness: 'error')
204
+ stale_model = described_class.new(root: root, staleness: :error)
172
205
 
173
206
  expect do
174
207
  stale_model.summary_for('lib/foo.rb')
175
208
  end.to raise_error(SimpleCovMcp::FileNotFoundError, /File not found/)
176
209
  end
210
+
211
+ it 'raises FileError when lookup_lines raises RuntimeError' do
212
+ allow(SimpleCovMcp::CovUtil).to receive(:lookup_lines)
213
+ .and_raise(RuntimeError, 'Could not find coverage data')
214
+
215
+ expect do
216
+ model.summary_for('lib/some_file.rb')
217
+ end.to raise_error(SimpleCovMcp::FileError, /No coverage data found for file/)
218
+ end
177
219
  end
178
220
 
179
221
  describe 'resultset directory handling' do
@@ -194,7 +236,7 @@ RSpec.describe SimpleCovMcp::CoverageModel do
194
236
 
195
237
  expect(data['summary']['total']).to eq(5)
196
238
  expect(data['summary']['covered']).to eq(3)
197
- expect(data['summary']['pct']).to be_within(0.01).of(60.0)
239
+ expect(data['summary']['percentage']).to be_within(0.01).of(60.0)
198
240
  end
199
241
 
200
242
  it 'returns detailed data using branch-derived hits' do
@@ -234,6 +276,40 @@ RSpec.describe SimpleCovMcp::CoverageModel do
234
276
 
235
277
  describe 'multiple suites in resultset' do
236
278
  let(:resultset_path) { '/tmp/multi_suite_resultset.json' }
279
+ let(:suite_a_cov) do
280
+ {
281
+ File.join(root, 'lib', 'foo.rb') => { 'lines' => [1, 0, nil, 2] }
282
+ }
283
+ end
284
+ let(:suite_b_cov) do
285
+ {
286
+ File.join(root, 'lib', 'bar.rb') => { 'lines' => [0, 1, 1] }
287
+ }
288
+ end
289
+ let(:resultset) do
290
+ {
291
+ 'RSpec' => { 'timestamp' => 100, 'coverage' => suite_a_cov },
292
+ 'Cucumber' => { 'timestamp' => 200, 'coverage' => suite_b_cov }
293
+ }
294
+ end
295
+
296
+ let(:shared_file) { File.join(root, 'lib', 'foo.rb') }
297
+ let(:suite_a_cov_combined) do
298
+ {
299
+ shared_file => { 'lines' => [1, 0, nil, 0] }
300
+ }
301
+ end
302
+ let(:suite_b_cov_combined) do
303
+ {
304
+ shared_file => { 'lines' => [0, 3, nil, 1] }
305
+ }
306
+ end
307
+ let(:resultset_combined) do
308
+ {
309
+ 'RSpec' => { 'timestamp' => 100, 'coverage' => suite_a_cov_combined },
310
+ 'Cucumber' => { 'timestamp' => 150, 'coverage' => suite_b_cov_combined }
311
+ }
312
+ end
237
313
 
238
314
  before do
239
315
  allow(SimpleCovMcp::CovUtil).to receive(:find_resultset).and_wrap_original do
@@ -246,23 +322,11 @@ RSpec.describe SimpleCovMcp::CoverageModel do
246
322
  original.call(search_root, resultset: resultset)
247
323
  end
248
324
  end
249
- allow(File).to receive(:read).and_call_original
325
+ # This line might need to be removed as we now mock JSON.load_file directly
250
326
  end
251
327
 
252
328
  it 'merges coverage data from multiple suites while keeping latest timestamp' do
253
- suite_a_cov = {
254
- File.join(root, 'lib', 'foo.rb') => { 'lines' => [1, 0, nil, 2] }
255
- }
256
- suite_b_cov = {
257
- File.join(root, 'lib', 'bar.rb') => { 'lines' => [0, 1, 1] }
258
- }
259
-
260
- resultset = {
261
- 'RSpec' => { 'timestamp' => 100, 'coverage' => suite_a_cov },
262
- 'Cucumber' => { 'timestamp' => 200, 'coverage' => suite_b_cov }
263
- }
264
-
265
- allow(File).to receive(:read).with(resultset_path).and_return(resultset.to_json)
329
+ allow(JSON).to receive(:load_file).with(resultset_path).and_return(resultset)
266
330
 
267
331
  model = described_class.new(root: root)
268
332
  files = model.all_files(sort_order: :ascending)
@@ -274,20 +338,7 @@ RSpec.describe SimpleCovMcp::CoverageModel do
274
338
  end
275
339
 
276
340
  it 'combines coverage arrays when the same file appears in multiple suites' do
277
- shared_file = File.join(root, 'lib', 'foo.rb')
278
- suite_a_cov = {
279
- shared_file => { 'lines' => [1, 0, nil, 0] }
280
- }
281
- suite_b_cov = {
282
- shared_file => { 'lines' => [0, 3, nil, 1] }
283
- }
284
-
285
- resultset = {
286
- 'RSpec' => { 'timestamp' => 100, 'coverage' => suite_a_cov },
287
- 'Cucumber' => { 'timestamp' => 150, 'coverage' => suite_b_cov }
288
- }
289
-
290
- allow(File).to receive(:read).with(resultset_path).and_return(resultset.to_json)
341
+ allow(JSON).to receive(:load_file).with(resultset_path).and_return(resultset_combined)
291
342
 
292
343
  model = described_class.new(root: root)
293
344
  detailed = model.detailed_for('lib/foo.rb')
@@ -367,7 +418,6 @@ RSpec.describe SimpleCovMcp::CoverageModel do
367
418
 
368
419
  it 'accepts sort_order parameter' do
369
420
  # Test that sort_order parameter is passed through correctly
370
- rows_desc = model.all_files(sort_order: :descending)
371
421
  output_asc = model.format_table(sort_order: :ascending)
372
422
  output_desc = model.format_table(sort_order: :descending)
373
423
 
@@ -14,42 +14,45 @@ RSpec.describe 'SIMPLECOV_MCP_OPTS Environment Variable' do
14
14
 
15
15
  describe 'CLI option parsing from environment' do
16
16
  it 'parses simple options from SIMPLECOV_MCP_OPTS' do
17
- ENV['SIMPLECOV_MCP_OPTS'] = '--error-mode off --json'
17
+ ENV['SIMPLECOV_MCP_OPTS'] = '--error-mode off --format json'
18
+ env_opts = SimpleCovMcp.send(:extract_env_opts)
18
19
 
19
- begin
20
- silence_output { cli.send(:run, ['summary', 'lib/foo.rb']) }
21
- rescue Exception => e
22
- # Expected to fail due to missing file, but options should be parsed
23
- puts "DEBUG: Caught exception: #{e.class}: #{e.message}" if ENV['DEBUG']
20
+ swallow_system_exit do
21
+ silence_output do
22
+ cli.send(:run, env_opts + ['summary', 'lib/foo.rb'])
23
+ end
24
24
  end
25
-
25
+ rescue SimpleCovMcp::Error => e
26
+ # Expected to fail due to missing file, but options should be parsed
27
+ puts "DEBUG: Caught exception: #{e.class}: #{e.message}" if ENV['DEBUG']
28
+ ensure
26
29
  expect(cli.config.error_mode).to eq(:off)
27
- expect(cli.config.json).to be true
30
+ expect(cli.config.format).to eq(:json)
28
31
  end
29
32
 
30
33
  it 'handles quoted options with spaces' do
31
34
  test_path = File.join(Dir.tmpdir, 'test path with spaces', '.resultset.json')
32
35
  ENV['SIMPLECOV_MCP_OPTS'] = "--resultset \"#{test_path}\""
36
+ env_opts = SimpleCovMcp.send(:extract_env_opts)
33
37
 
34
- # Stub exit method to prevent process termination
35
- allow_any_instance_of(Object).to receive(:exit)
36
-
37
- # silence_output captures the expected error message from the CLI trying to
38
- # load the (non-existent) resultset, preventing it from leaking to the console.
39
- silence_output do
40
- cli.send(:run, ['--help'])
38
+ exit_status = swallow_system_exit do
39
+ silence_output do
40
+ cli.send(:run, env_opts + ['--help'])
41
+ end
41
42
  end
42
43
 
44
+ expect(exit_status).to eq(0) # --help exits cleanly
43
45
  expect(cli.config.resultset).to eq(test_path)
44
46
  end
45
47
 
46
48
  it 'supports setting log-file to stdout from environment' do
47
49
  ENV['SIMPLECOV_MCP_OPTS'] = '--log-file stdout'
50
+ env_opts = SimpleCovMcp.send(:extract_env_opts)
48
51
 
49
- allow_any_instance_of(Object).to receive(:exit)
50
-
51
- silence_output do
52
- cli.send(:run, ['--help'])
52
+ swallow_system_exit do
53
+ silence_output do
54
+ cli.send(:run, env_opts + ['--help'])
55
+ end
53
56
  end
54
57
 
55
58
  expect(cli.config.log_file).to eq('stdout')
@@ -57,15 +60,17 @@ RSpec.describe 'SIMPLECOV_MCP_OPTS Environment Variable' do
57
60
 
58
61
  it 'command line arguments override environment options' do
59
62
  ENV['SIMPLECOV_MCP_OPTS'] = '--error-mode off'
63
+ env_opts = SimpleCovMcp.send(:extract_env_opts)
60
64
 
61
65
  begin
62
- silence_output { cli.send(:run, ['--error-mode', 'trace', 'summary', 'lib/foo.rb']) }
66
+ args = env_opts + ['--error-mode', 'debug', 'summary', 'lib/foo.rb']
67
+ silence_output { cli.send(:run, args) }
63
68
  rescue SystemExit, SimpleCovMcp::Error
64
69
  # Expected to fail, but options should be parsed
65
70
  end
66
71
 
67
72
  # Command line should override environment
68
- expect(cli.config.error_mode).to eq(:trace)
73
+ expect(cli.config.error_mode).to eq(:debug)
69
74
  end
70
75
 
71
76
  it 'handles malformed SIMPLECOV_MCP_OPTS gracefully' do
@@ -78,13 +83,13 @@ RSpec.describe 'SIMPLECOV_MCP_OPTS Environment Variable' do
78
83
 
79
84
  it 'returns empty array when SIMPLECOV_MCP_OPTS is not set' do
80
85
  # ENV is already cleared by around block
81
- opts = cli.send(:parse_env_opts)
86
+ opts = SimpleCovMcp.send(:extract_env_opts)
82
87
  expect(opts).to eq([])
83
88
  end
84
89
 
85
90
  it 'returns empty array when SIMPLECOV_MCP_OPTS is empty' do
86
91
  ENV['SIMPLECOV_MCP_OPTS'] = ''
87
- opts = cli.send(:parse_env_opts)
92
+ opts = SimpleCovMcp.send(:extract_env_opts)
88
93
  expect(opts).to eq([])
89
94
  end
90
95
  end
@@ -96,7 +101,7 @@ RSpec.describe 'SIMPLECOV_MCP_OPTS Environment Variable' do
96
101
  # This would normally be MCP mode (no TTY, no subcommand)
97
102
  stdin = double('stdin', tty?: false)
98
103
 
99
- env_opts = SimpleCovMcp.send(:parse_env_opts_for_mode_detection)
104
+ env_opts = SimpleCovMcp.send(:extract_env_opts)
100
105
  full_argv = env_opts + []
101
106
 
102
107
  expect(SimpleCovMcp::ModeDetector.cli_mode?(full_argv, stdin: stdin)).to be true
@@ -106,7 +111,7 @@ RSpec.describe 'SIMPLECOV_MCP_OPTS Environment Variable' do
106
111
  ENV['SIMPLECOV_MCP_OPTS'] = '--option "unclosed quote'
107
112
 
108
113
  # Should return empty array and not crash
109
- opts = SimpleCovMcp.send(:parse_env_opts_for_mode_detection)
114
+ opts = SimpleCovMcp.send(:extract_env_opts)
110
115
  expect(opts).to eq([])
111
116
  end
112
117
 
@@ -114,15 +119,14 @@ RSpec.describe 'SIMPLECOV_MCP_OPTS Environment Variable' do
114
119
  ENV['SIMPLECOV_MCP_OPTS'] = '--force-cli'
115
120
 
116
121
  # Mock STDIN to not be a TTY (would normally trigger MCP server mode)
117
- allow(STDIN).to receive(:tty?).and_return(false)
118
-
119
- # Stub exit to prevent process termination
120
- allow_any_instance_of(Object).to receive(:exit)
122
+ allow($stdin).to receive(:tty?).and_return(false)
121
123
 
122
124
  # Run with --help which should produce help output
123
125
  output = nil
124
126
  silence_output do |out, err|
125
- SimpleCovMcp.run(['--help'])
127
+ swallow_system_exit do
128
+ SimpleCovMcp.run(['--help'])
129
+ end
126
130
  output = out.string + err.string
127
131
  end
128
132
 
@@ -135,7 +139,7 @@ RSpec.describe 'SIMPLECOV_MCP_OPTS Environment Variable' do
135
139
  ENV['SIMPLECOV_MCP_OPTS'] = ''
136
140
 
137
141
  # Mock STDIN to not be a TTY and to provide valid JSON-RPC
138
- allow(STDIN).to receive(:tty?).and_return(false)
142
+ allow($stdin).to receive(:tty?).and_return(false)
139
143
 
140
144
  # Provide a minimal JSON-RPC request that the server can handle
141
145
  json_request = JSON.generate({
@@ -149,7 +153,7 @@ RSpec.describe 'SIMPLECOV_MCP_OPTS Environment Variable' do
149
153
  }
150
154
  })
151
155
 
152
- allow(STDIN).to receive(:gets).and_return(json_request, nil)
156
+ allow($stdin).to receive(:gets).and_return(json_request, nil)
153
157
 
154
158
  # Capture output to verify MCP server response
155
159
  output = nil
@@ -167,16 +171,15 @@ RSpec.describe 'SIMPLECOV_MCP_OPTS Environment Variable' do
167
171
  describe 'integration with actual CLI usage' do
168
172
  it 'works end-to-end with --resultset option' do
169
173
  test_resultset = File.join(Dir.tmpdir, 'test_coverage', '.resultset.json')
170
- ENV['SIMPLECOV_MCP_OPTS'] = "--resultset #{test_resultset} --json"
171
-
172
- allow_any_instance_of(Object).to receive(:exit)
174
+ ENV['SIMPLECOV_MCP_OPTS'] = "--resultset #{test_resultset} --format json"
175
+ env_opts = SimpleCovMcp.send(:extract_env_opts)
173
176
 
174
- expect do
175
- silence_output { cli.send(:run, ['--help']) }
176
- end.not_to raise_error
177
+ swallow_system_exit do
178
+ silence_output { cli.send(:run, env_opts + ['--help']) }
179
+ end
177
180
 
178
181
  expect(cli.config.resultset).to eq(test_resultset)
179
- expect(cli.config.json).to be true
182
+ expect(cli.config.format).to eq(:json)
180
183
  end
181
184
  end
182
185
  end
data/spec/spec_helper.rb CHANGED
@@ -5,9 +5,17 @@ begin
5
5
  require 'simplecov'
6
6
  SimpleCov.start do
7
7
  enable_coverage :branch if SimpleCov.respond_to?(:enable_coverage)
8
- add_filter %r{^/spec/}
8
+ add_filter(/^\/spec\//)
9
9
  track_files 'lib/**/*.rb'
10
10
  end
11
+
12
+ # Report lowest coverage files at the end of the test run
13
+ SimpleCov.at_exit do
14
+ SimpleCov.result.format!
15
+ require 'simplecov_mcp'
16
+ report = SimpleCovMcp::CoverageReporter.report(threshold: 80, count: 5)
17
+ puts report if report
18
+ end
11
19
  rescue LoadError
12
20
  warn 'SimpleCov not available; skipping coverage'
13
21
  end
@@ -57,14 +65,16 @@ def mock_resultset_with_metadata(root, metadata, coverage: nil)
57
65
  File.join(root, 'lib', 'bar.rb') => { 'lines' => [0, 0, 1] }
58
66
  }
59
67
 
60
- fake_resultset = {
68
+ fake_resultset_hash = {
61
69
  'RSpec' => {
62
70
  'coverage' => coverage || default_coverage
63
71
  }.merge(metadata)
64
72
  }
65
73
 
66
- allow(File).to receive(:read).and_call_original
67
- allow(File).to receive(:read).with(end_with('.resultset.json')).and_return(fake_resultset.to_json)
74
+ allow(JSON).to receive(:load_file).and_call_original # Allow real JSON.load_file for other calls
75
+
76
+ allow(JSON).to receive(:load_file).with(end_with('.resultset.json'))
77
+ .and_return(fake_resultset_hash)
68
78
  allow(SimpleCovMcp::CovUtil).to receive(:find_resultset)
69
79
  .and_wrap_original do |method, search_root, resultset: nil|
70
80
  if File.absolute_path(search_root) == abs_root && (resultset.nil? || resultset.to_s.empty?)
@@ -76,8 +86,8 @@ def mock_resultset_with_metadata(root, metadata, coverage: nil)
76
86
  end
77
87
 
78
88
  # Automatically require all files in spec/support and spec/shared_examples
79
- Dir[File.join(__dir__, 'support', '**', '*.rb')].sort.each { |f| require f }
80
- Dir[File.join(__dir__, 'shared_examples', '**', '*.rb')].sort.each { |f| require f }
89
+ Dir[File.join(__dir__, 'support', '**', '*.rb')].each { |f| require f }
90
+ Dir[File.join(__dir__, 'shared_examples', '**', '*.rb')].each { |f| require f }
81
91
 
82
92
  RSpec.configure do |config|
83
93
  config.example_status_persistence_file_path = '.rspec_status'
@@ -85,107 +95,32 @@ RSpec.configure do |config|
85
95
  config.order = :defined
86
96
  Kernel.srand config.seed
87
97
 
88
- # Suppress logging during tests by redirecting to a null device
89
- SimpleCovMcp.default_log_file = File::NULL
90
- SimpleCovMcp.active_log_file = File::NULL
91
- end
98
+ # Suppress logging during tests by redirecting to /dev/null
99
+ # This is cheap and doesn't break tests that verify logging behavior
100
+ SimpleCovMcp.default_log_file = 'stderr'
101
+ SimpleCovMcp.active_log_file = 'stderr'
92
102
 
93
- # Shared test helpers
94
- module TestIOHelpers
95
- # Suppress stdout/stderr within the given block, yielding the StringIOs
96
- def silence_output
97
- original_stdout = $stdout
98
- original_stderr = $stderr
99
- $stdout = StringIO.new
100
- $stderr = StringIO.new
101
- yield $stdout, $stderr
102
- ensure
103
- $stdout = original_stdout
104
- $stderr = original_stderr
103
+ # Reset log file after each test to ensure tests that change it don't pollute others
104
+ config.after do
105
+ SimpleCovMcp.active_log_file = File::NULL
105
106
  end
106
107
 
107
- # Stub staleness checking to return a specific value
108
- # @param value [String, false] The staleness value to return ('L', 'T', 'M', or false)
109
- def stub_staleness_check(value)
110
- checker_double = instance_double(SimpleCovMcp::StalenessChecker)
111
- allow(checker_double).to receive_messages(stale_for_file?: value, off?: false)
112
- allow(checker_double).to receive(:check_file!)
113
- allow(SimpleCovMcp::StalenessChecker).to receive(:new).and_return(checker_double)
114
- end
115
- end
116
-
117
- # CLI test helpers
118
- module CLITestHelpers
119
- # Run CLI with the given arguments and return [stdout, stderr, exit_status]
120
- def run_cli_with_status(*argv)
121
- cli = SimpleCovMcp::CoverageCLI.new
122
- status = nil
123
- out_str = err_str = nil
124
- silence_output do |out, err|
125
- begin
126
- cli.run(argv.flatten)
127
- status = 0
128
- rescue SystemExit => e
129
- status = e.status
130
- end
131
- out_str = out.string
132
- err_str = err.string
133
- end
134
- [out_str, err_str, status]
135
- end
136
- end
137
-
138
- # MCP Tool shared examples and helpers
139
- module MCPToolTestHelpers
140
- def setup_mcp_response_stub
141
- # Standardized MCP::Tool::Response stub that works for all tools
142
- response_class = Class.new do
143
- attr_reader :payload, :meta
144
-
145
- def initialize(payload, meta: nil)
146
- @payload = payload
147
- @meta = meta
148
- end
149
- end
150
- stub_const('MCP::Tool::Response', response_class)
151
- end
152
-
153
- def expect_mcp_text_json(response, expected_keys: [])
154
- item = response.payload.first
155
-
156
- # Check for a 'text' part
157
- expect(item['type']).to eq('text')
158
- expect(item).to have_key('text')
159
-
160
- # Parse and validate JSON content
161
- data = JSON.parse(item['text'])
162
-
163
- # Check for expected keys
164
- expected_keys.each do |key|
165
- expect(data).to have_key(key)
166
- end
167
-
168
- [data, item] # Return for additional custom assertions
169
- end
170
- end
171
-
172
-
173
-
174
- RSpec.configure do |config|
175
108
  config.include TestIOHelpers
176
109
  config.include CLITestHelpers
177
110
  config.include MCPToolTestHelpers
111
+ config.include MockingHelpers
112
+ config.include ControlFlowHelpers
178
113
  end
179
114
 
180
115
  # Custom matchers
181
116
  RSpec::Matchers.define :show_source_table_or_fallback do
182
117
  match do |output|
183
- has_table_header = output.match?(/(^|\n)\s*Line\s+\|\s+Source/)
118
+ has_table_header = output.match?(/(^|\n)\s*Line\s*\|\s+Source/)
184
119
  has_fallback = output.include?('[source not available]')
185
120
  has_table_header || has_fallback
186
121
  end
187
122
 
188
- failure_message do |output|
123
+ failure_message do |_output|
189
124
  "expected output to include a source table header (e.g., 'Line | Source') " \
190
125
  "or the fallback '[source not available]'"
191
126
  end
@@ -5,6 +5,7 @@ require 'fileutils'
5
5
 
6
6
  RSpec.describe SimpleCovMcp::StalenessChecker do
7
7
  let(:tmpdir) { Dir.mktmpdir('scmcp-stale') }
8
+
8
9
  after { FileUtils.remove_entry(tmpdir) if tmpdir && File.directory?(tmpdir) }
9
10
 
10
11
  def write_file(path, lines)
@@ -57,8 +58,8 @@ RSpec.describe SimpleCovMcp::StalenessChecker do
57
58
  end
58
59
  end
59
60
 
60
- context 'compute_file_staleness_details' do
61
- include_examples 'a staleness check',
61
+ context 'when computing file staleness details' do
62
+ it_behaves_like 'a staleness check',
62
63
  description: 'detects newer file vs coverage timestamp',
63
64
  file_lines: ['a', 'b'],
64
65
  coverage_lines: [1, 1],
@@ -75,7 +76,7 @@ RSpec.describe SimpleCovMcp::StalenessChecker do
75
76
  expected_stale_char: 'T',
76
77
  expected_error: SimpleCovMcp::CoverageDataStaleError
77
78
 
78
- include_examples 'a staleness check',
79
+ it_behaves_like 'a staleness check',
79
80
  description: 'detects length mismatch between source and coverage',
80
81
  file_lines: ['a', 'b', 'c', 'd'],
81
82
  coverage_lines: [1, 1],
@@ -92,7 +93,7 @@ RSpec.describe SimpleCovMcp::StalenessChecker do
92
93
  expected_stale_char: 'L',
93
94
  expected_error: SimpleCovMcp::CoverageDataStaleError
94
95
 
95
- include_examples 'a staleness check',
96
+ it_behaves_like 'a staleness check',
96
97
  description: 'treats missing file as stale',
97
98
  file_lines: nil,
98
99
  coverage_lines: [1, 1, 1],
@@ -107,7 +108,7 @@ RSpec.describe SimpleCovMcp::StalenessChecker do
107
108
  expected_stale_char: 'M',
108
109
  expected_error: SimpleCovMcp::CoverageDataStaleError
109
110
 
110
- include_examples 'a staleness check',
111
+ it_behaves_like 'a staleness check',
111
112
  description: 'is not stale when timestamps and lengths match',
112
113
  file_lines: ['a', 'b', 'c'],
113
114
  coverage_lines: [1, 0, nil],
@@ -123,7 +124,7 @@ RSpec.describe SimpleCovMcp::StalenessChecker do
123
124
  expected_error: nil
124
125
  end
125
126
 
126
- context 'missing_trailing_newline? edge cases' do
127
+ context 'when handling missing_trailing_newline? edge cases' do
127
128
  let(:checker) do
128
129
  described_class.new(root: tmpdir, resultset: nil, mode: 'off', timestamp: Time.now)
129
130
  end
@@ -192,7 +193,7 @@ RSpec.describe SimpleCovMcp::StalenessChecker do
192
193
  end
193
194
  end
194
195
 
195
- context 'line count adjustment with missing trailing newline' do
196
+ context 'when adjusting line count with missing trailing newline' do
196
197
  let(:checker) do
197
198
  described_class.new(root: tmpdir, resultset: nil, mode: 'off', timestamp: Time.now)
198
199
  end
@@ -246,7 +247,7 @@ RSpec.describe SimpleCovMcp::StalenessChecker do
246
247
  end
247
248
  end
248
249
 
249
- context 'safe_count_lines edge cases' do
250
+ context 'when handling safe_count_lines edge cases' do
250
251
  let(:checker) do
251
252
  described_class.new(root: tmpdir, resultset: nil, mode: 'off', timestamp: Time.now)
252
253
  end
@@ -287,7 +288,7 @@ RSpec.describe SimpleCovMcp::StalenessChecker do
287
288
  end
288
289
  end
289
290
 
290
- context 'rel method with path prefix mismatches' do
291
+ context 'when rel has path prefix mismatches' do
291
292
  let(:checker) do
292
293
  described_class.new(root: tmpdir, resultset: nil, mode: 'off', timestamp: Time.now)
293
294
  end