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,789 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ # Timeout for MCP server operations (increased for JRuby compatibility)
6
+ MCP_TIMEOUT = 5
7
+
8
+ RSpec.describe 'SimpleCov MCP Integration Tests' do
9
+ let(:project_root) { (FIXTURES_DIR / 'project1').to_s }
10
+ let(:coverage_dir) { File.join(project_root, 'coverage') }
11
+ let(:resultset_path) { File.join(coverage_dir, '.resultset.json') }
12
+
13
+ describe 'End-to-End Coverage Model Functionality' do
14
+ it 'loads fixture coverage and surfaces core stats across APIs' do
15
+ model = CovLoupe::CoverageModel.new(root: project_root, resultset: coverage_dir)
16
+
17
+ files = model.all_files
18
+ expect(files.length).to eq(2)
19
+ files_by_name = files.to_h { |f| [File.basename(f['file']), f] }
20
+
21
+ foo = files_by_name.fetch('foo.rb')
22
+ bar = files_by_name.fetch('bar.rb')
23
+ expect(foo['percentage']).to be_within(0.01).of(66.67)
24
+ expect(bar['percentage']).to be_within(0.01).of(33.33)
25
+
26
+ raw = model.raw_for('lib/foo.rb')
27
+ expect(raw['lines']).to eq([1, 0, nil, 2])
28
+
29
+ summary = model.summary_for('lib/foo.rb')
30
+ expect(summary['summary']).to include('covered' => 2, 'total' => 3)
31
+
32
+ uncovered = model.uncovered_for('lib/foo.rb')
33
+ expect(uncovered['uncovered']).to eq([2])
34
+
35
+ detailed = model.detailed_for('lib/foo.rb')
36
+ expect(detailed['lines']).to include({ 'line' => 2, 'hits' => 0, 'covered' => false })
37
+
38
+ table = model.format_table
39
+ expect(table).to include('lib/foo.rb', 'lib/bar.rb', '66.67', '33.33')
40
+ data_lines = table.split("\n").select { |line| line.include?('lib/') }
41
+ expect(data_lines.first).to include('lib/foo.rb') # Highest coverage first (descending default)
42
+ expect(data_lines.last).to include('lib/bar.rb')
43
+ end
44
+ end
45
+
46
+ describe 'CLI Integration with Real Coverage Data' do
47
+ it 'executes all major CLI commands without errors' do
48
+ # Test list command
49
+ list_output, _err, status = run_cli_with_status('--root', project_root, '--resultset',
50
+ coverage_dir, 'list')
51
+ expect(status).to eq(0)
52
+ expect(list_output).to include('lib/foo.rb', 'lib/bar.rb')
53
+ expect(list_output).to include('66.67', '33.33')
54
+ data_lines = list_output.lines.select { |line| line.include?('lib/') }
55
+ expect(data_lines.first).to include('lib/foo.rb') # Highest coverage first (descending default)
56
+ expect(data_lines.last).to include('lib/bar.rb')
57
+
58
+ # Test summary command
59
+ summary_output, _err, status = run_cli_with_status('--root', project_root, '--resultset',
60
+ coverage_dir, 'summary', 'lib/foo.rb')
61
+ expect(status).to eq(0)
62
+ expect(summary_output).to include('│') # Table format
63
+ expect(summary_output).to include('66.67%')
64
+ expect(summary_output).to include('2')
65
+ expect(summary_output).to include('3')
66
+
67
+ # Test JSON output
68
+ json_output, _err, status = run_cli_with_status(
69
+ '--format', 'json', '--root', project_root, '--resultset', coverage_dir,
70
+ 'summary', 'lib/foo.rb'
71
+ )
72
+ expect(status).to eq(0)
73
+ json_data = JSON.parse(json_output)
74
+ expect(json_data).to include('file', 'summary')
75
+ expect(json_data['summary']).to include('covered' => 2, 'total' => 3)
76
+ end
77
+
78
+ it 'handles different output formats correctly' do
79
+ # Test uncovered command with different outputs
80
+ uncovered_output, _err, status = run_cli_with_status(
81
+ '--root', project_root, '--resultset', coverage_dir, 'uncovered', 'lib/foo.rb'
82
+ )
83
+ expect(status).to eq(0)
84
+ expect(uncovered_output).to include('│') # Table format
85
+
86
+ # Test detailed command
87
+ detailed_output, _err, status = run_cli_with_status(
88
+ '--root', project_root, '--resultset', coverage_dir, 'detailed', 'lib/foo.rb'
89
+ )
90
+ expect(status).to eq(0)
91
+ expect(detailed_output).to include('Line', 'Hits', 'Covered')
92
+ end
93
+ end
94
+
95
+ describe 'MCP Tool Integration with Real Data' do
96
+ let(:server_context) { instance_double('ServerContext').as_null_object }
97
+
98
+ before do
99
+ setup_mcp_response_stub
100
+ end
101
+
102
+ it 'executes all MCP tools with real coverage data' do
103
+ # Test coverage summary tool
104
+ summary_response = CovLoupe::Tools::CoverageSummaryTool.call(
105
+ path: 'lib/foo.rb',
106
+ root: project_root,
107
+ resultset: coverage_dir,
108
+ server_context: server_context
109
+ )
110
+
111
+ data, _item = expect_mcp_text_json(summary_response, expected_keys: ['file', 'summary'])
112
+ expect(data['summary']).to include('covered' => 2, 'total' => 3)
113
+
114
+ # Test raw coverage tool
115
+ raw_response = CovLoupe::Tools::CoverageRawTool.call(
116
+ path: 'lib/foo.rb',
117
+ root: project_root,
118
+ resultset: coverage_dir,
119
+ server_context: server_context
120
+ )
121
+
122
+ raw_data, _raw_item = expect_mcp_text_json(raw_response, expected_keys: ['file', 'lines'])
123
+ expect(raw_data['lines']).to eq([1, 0, nil, 2])
124
+
125
+ # Test all files tool
126
+ all_files_response = CovLoupe::Tools::AllFilesCoverageTool.call(
127
+ root: project_root,
128
+ resultset: coverage_dir,
129
+ server_context: server_context
130
+ )
131
+
132
+ all_data, = expect_mcp_text_json(all_files_response, expected_keys: ['files', 'counts'])
133
+ expect(all_data['files'].length).to eq(2)
134
+ expect(all_data['counts']['total']).to eq(2)
135
+ end
136
+
137
+ it 'provides consistent data across different tools' do
138
+ # Get data from summary tool
139
+ summary_response = CovLoupe::Tools::CoverageSummaryTool.call(
140
+ path: 'lib/foo.rb',
141
+ root: project_root,
142
+ resultset: coverage_dir,
143
+ server_context: server_context
144
+ )
145
+ summary_data, = expect_mcp_text_json(summary_response)
146
+
147
+ # Get data from detailed tool
148
+ detailed_response = CovLoupe::Tools::CoverageDetailedTool.call(
149
+ path: 'lib/foo.rb',
150
+ root: project_root,
151
+ resultset: coverage_dir,
152
+ server_context: server_context
153
+ )
154
+ detailed_data, = expect_mcp_text_json(detailed_response)
155
+
156
+ # Verify consistency between tools
157
+ expect(summary_data['summary']['covered']).to eq(2)
158
+ expect(summary_data['summary']['total']).to eq(3)
159
+ expect(detailed_data['summary']['covered']).to eq(2)
160
+ expect(detailed_data['summary']['total']).to eq(3)
161
+
162
+ # Count covered lines in detailed data
163
+ covered_lines = detailed_data['lines'].count { |line| line['covered'] }
164
+ expect(covered_lines).to eq(2)
165
+ end
166
+ end
167
+
168
+ describe 'Error Handling Integration' do
169
+ it 'handles missing files gracefully' do
170
+ model = CovLoupe::CoverageModel.new(root: project_root, resultset: coverage_dir)
171
+
172
+ expect do
173
+ model.summary_for('lib/nonexistent.rb')
174
+ end.to raise_error(CovLoupe::FileError, /No coverage entry found/)
175
+ end
176
+
177
+ it 'handles invalid resultset paths gracefully' do
178
+ expect do
179
+ CovLoupe::CoverageModel.new(root: project_root, resultset: '/nonexistent/path')
180
+ end.to raise_error(CovLoupe::ResultsetNotFoundError, /Specified resultset not found/)
181
+ end
182
+
183
+ it 'provides helpful CLI error messages' do
184
+ _output, error, status = run_cli_with_status(
185
+ '--root', project_root, '--resultset', coverage_dir, 'summary', 'lib/nonexistent.rb'
186
+ )
187
+
188
+ expect(status).to eq(1)
189
+ expect(error).to include('File error:', 'No coverage entry found')
190
+ end
191
+ end
192
+
193
+ describe 'Multi-File Scenarios' do
194
+ it 'handles projects with mixed coverage levels' do
195
+ model = CovLoupe::CoverageModel.new(root: project_root, resultset: coverage_dir)
196
+
197
+ # Get all files and verify range of coverage
198
+ files = model.all_files
199
+ coverages = files.map { |f| f['percentage'] }
200
+
201
+ expect(coverages.min).to be < 50 # bar.rb at ~33%
202
+ expect(coverages.max).to be > 50 # foo.rb at ~67%
203
+ expect(coverages).to include(a_value_within(0.1).of(33.33))
204
+ expect(coverages).to include(a_value_within(0.1).of(66.67))
205
+ end
206
+
207
+ it 'generates comprehensive project reports' do
208
+ model = CovLoupe::CoverageModel.new(root: project_root, resultset: coverage_dir)
209
+
210
+ table = model.format_table
211
+
212
+ # Should show both files with different coverage levels
213
+ expect(table).to match(/lib\/bar\.rb.*33\.33/)
214
+ expect(table).to match(/lib\/foo\.rb.*66\.67/)
215
+
216
+ # Should show project totals
217
+ expect(table).to include('Files: total 2')
218
+ end
219
+ end
220
+
221
+ describe 'MCP Server Protocol Integration', :slow do
222
+ # spec/ is one level deep, so ../.. goes up to repo root
223
+ let(:repo_root) { File.expand_path('..', __dir__) }
224
+ let(:exe_path) { File.join(repo_root, 'exe', 'cov-loupe') }
225
+ let(:lib_path) { File.join(repo_root, 'lib') }
226
+
227
+ let(:default_env) do
228
+ {
229
+ 'RUBY_LIB' => lib_path,
230
+ 'COV_LOUPE_OPTS' => "--root #{project_root} --resultset #{coverage_dir} --log-file /dev/null"
231
+ }
232
+ end
233
+
234
+ def runner_args(env: default_env, timeout: 5)
235
+ {
236
+ env: env,
237
+ lib_path: lib_path,
238
+ exe_path: exe_path,
239
+ timeout: timeout
240
+ }
241
+ end
242
+
243
+ # Run the MCP executable with a single JSON-RPC request hash and return the captured streams.
244
+ def run_mcp_json(request_hash, env: default_env, timeout: MCP_TIMEOUT)
245
+ Spec::Support::McpRunner.call_json(
246
+ request_hash,
247
+ **runner_args(env: env, timeout: timeout)
248
+ )
249
+ end
250
+
251
+ # Run the MCP executable with a sequence of JSON-RPC requests (one per line).
252
+ def run_mcp_json_stream(request_hashes, env: default_env, timeout: MCP_TIMEOUT)
253
+ Spec::Support::McpRunner.call_json_stream(
254
+ request_hashes,
255
+ **runner_args(env: env, timeout: timeout)
256
+ )
257
+ end
258
+
259
+ # Run the MCP executable with a raw string payload (already encoded as needed).
260
+ def run_mcp_input(input, env: default_env, timeout: MCP_TIMEOUT)
261
+ Spec::Support::McpRunner.call(
262
+ input: input,
263
+ **runner_args(env: env, timeout: timeout)
264
+ )
265
+ end
266
+
267
+ def parse_jsonrpc_response(output)
268
+ # MCP server should only write JSON-RPC responses to stdout.
269
+ # Force UTF-8 encoding to handle any binary data in the output stream.
270
+ safe_output = output.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
271
+ safe_output.lines.each do |line|
272
+ stripped = line.strip
273
+ next if stripped.empty?
274
+
275
+ begin
276
+ parsed = JSON.parse(stripped)
277
+ rescue JSON::ParserError => e
278
+ raise "Unexpected non-JSON output from MCP server stdout: #{stripped.inspect} (#{e.message})"
279
+ end
280
+
281
+ return parsed if parsed['jsonrpc'] == '2.0'
282
+
283
+ raise "Unexpected JSON-RPC payload on stdout: #{stripped.inspect}"
284
+ end
285
+
286
+ raise "No JSON-RPC response found on stdout. Raw output: #{output.inspect}"
287
+ end
288
+
289
+ it 'starts MCP server without errors' do
290
+ request = {
291
+ jsonrpc: '2.0',
292
+ id: 1,
293
+ method: 'tools/list'
294
+ }
295
+
296
+ result = run_mcp_json(request)
297
+ stdout = result[:stdout]
298
+ stderr = result[:stderr]
299
+
300
+ # Should not crash with NameError about OptionParser
301
+ expect(stderr).not_to include('NameError')
302
+ expect(stderr).not_to include('uninitialized constant')
303
+ expect(stderr).not_to include('OptionParser')
304
+
305
+ # Should produce valid JSON-RPC output
306
+ response = parse_jsonrpc_response(stdout)
307
+ expect(response).not_to be_nil
308
+ expect(response['jsonrpc']).to eq('2.0')
309
+ expect(response['id']).to eq(1)
310
+ end
311
+
312
+ it 'handles tools/list request' do
313
+ request = {
314
+ jsonrpc: '2.0',
315
+ id: 2,
316
+ method: 'tools/list'
317
+ }
318
+
319
+ stdout = run_mcp_json(request)[:stdout]
320
+ response = parse_jsonrpc_response(stdout)
321
+
322
+ expect(response).to include('result')
323
+ tools = response['result']['tools']
324
+ expect(tools).to be_an(Array)
325
+
326
+ # Verify expected tools are registered
327
+ tool_names = tools.map { |t| t['name'] }
328
+ expect(tool_names).to include(
329
+ 'all_files_coverage_tool',
330
+ 'coverage_summary_tool',
331
+ 'coverage_raw_tool',
332
+ 'uncovered_lines_tool',
333
+ 'coverage_detailed_tool',
334
+ 'coverage_table_tool',
335
+ 'help_tool',
336
+ 'version_tool'
337
+ )
338
+ end
339
+
340
+ it 'executes coverage_summary_tool via JSON-RPC' do
341
+ request = {
342
+ jsonrpc: '2.0',
343
+ id: 3,
344
+ method: 'tools/call',
345
+ params: {
346
+ name: 'coverage_summary_tool',
347
+ arguments: {
348
+ path: 'lib/foo.rb',
349
+ root: project_root,
350
+ resultset: coverage_dir
351
+ }
352
+ }
353
+ }
354
+
355
+ stdout = run_mcp_json(request)[:stdout]
356
+ response = parse_jsonrpc_response(stdout)
357
+
358
+ expect(response['id']).to eq(3)
359
+ expect(response).to have_key('result')
360
+
361
+ content = response['result']['content']
362
+ expect(content).to be_an(Array)
363
+ expect(content.first['type']).to eq('text')
364
+
365
+ # Parse the JSON coverage data from the text response
366
+ coverage_data = JSON.parse(content.first['text'])
367
+ expect(coverage_data).to include('file', 'summary')
368
+ expect(coverage_data['summary']).to include('covered' => 2, 'total' => 3)
369
+ end
370
+
371
+ it 'executes all_files_coverage_tool via JSON-RPC' do
372
+ request = {
373
+ jsonrpc: '2.0',
374
+ id: 4,
375
+ method: 'tools/call',
376
+ params: {
377
+ name: 'all_files_coverage_tool',
378
+ arguments: {
379
+ root: project_root,
380
+ resultset: coverage_dir
381
+ }
382
+ }
383
+ }
384
+
385
+ stdout = run_mcp_json(request)[:stdout]
386
+ response = parse_jsonrpc_response(stdout)
387
+
388
+ expect(response['id']).to eq(4)
389
+ content = response['result']['content']
390
+ coverage_data = JSON.parse(content.first['text'])
391
+
392
+ expect(coverage_data).to include('files', 'counts')
393
+ expect(coverage_data['files']).to be_an(Array)
394
+ expect(coverage_data['files'].length).to eq(2)
395
+ expect(coverage_data['counts']['total']).to eq(2)
396
+ end
397
+
398
+ it 'executes uncovered_lines_tool via JSON-RPC' do
399
+ request = {
400
+ jsonrpc: '2.0',
401
+ id: 5,
402
+ method: 'tools/call',
403
+ params: {
404
+ name: 'uncovered_lines_tool',
405
+ arguments: {
406
+ path: 'lib/foo.rb',
407
+ root: project_root,
408
+ resultset: coverage_dir
409
+ }
410
+ }
411
+ }
412
+
413
+ stdout = run_mcp_json(request)[:stdout]
414
+ response = parse_jsonrpc_response(stdout)
415
+
416
+ expect(response['id']).to eq(5)
417
+ content = response['result']['content']
418
+ coverage_data = JSON.parse(content.first['text'])
419
+
420
+ expect(coverage_data).to include('file', 'uncovered', 'summary')
421
+ expect(coverage_data['uncovered']).to eq([2]) # Line 2 is uncovered
422
+ end
423
+
424
+ it 'executes help_tool via JSON-RPC' do
425
+ request = {
426
+ jsonrpc: '2.0',
427
+ id: 6,
428
+ method: 'tools/call',
429
+ params: {
430
+ name: 'help_tool',
431
+ arguments: {}
432
+ }
433
+ }
434
+
435
+ stdout = run_mcp_json(request)[:stdout]
436
+ response = parse_jsonrpc_response(stdout)
437
+
438
+ expect(response['id']).to eq(6)
439
+ content = response['result']['content']
440
+ expect(content.first['type']).to eq('text')
441
+
442
+ # Help tool returns JSON with tool list
443
+ help_data = JSON.parse(content.first['text'])
444
+ expect(help_data).to have_key('tools')
445
+ expect(help_data['tools']).to be_an(Array)
446
+ tool_names = help_data['tools'].map { |t| t['tool'] }
447
+ expect(tool_names).to include('coverage_summary_tool')
448
+ end
449
+
450
+ it 'executes version_tool via JSON-RPC' do
451
+ request = {
452
+ jsonrpc: '2.0',
453
+ id: 7,
454
+ method: 'tools/call',
455
+ params: {
456
+ name: 'version_tool',
457
+ arguments: {}
458
+ }
459
+ }
460
+
461
+ stdout = run_mcp_json(request)[:stdout]
462
+ response = parse_jsonrpc_response(stdout)
463
+
464
+ expect(response['id']).to eq(7)
465
+ content = response['result']['content']
466
+ expect(content.first['type']).to eq('text')
467
+
468
+ version_text = content.first['text']
469
+ # Version format is "CovLoupe version: X.Y.Z"
470
+ expect(version_text).to match(/CovLoupe version: \d+\.\d+/)
471
+ end
472
+
473
+ it 'executes validate_tool via JSON-RPC' do
474
+ request = {
475
+ jsonrpc: '2.0',
476
+ id: 80,
477
+ method: 'tools/call',
478
+ params: {
479
+ name: 'validate_tool',
480
+ arguments: {
481
+ code: '->(m) { true }',
482
+ root: project_root,
483
+ resultset: coverage_dir
484
+ }
485
+ }
486
+ }
487
+
488
+ stdout = run_mcp_json(request)[:stdout]
489
+ response = parse_jsonrpc_response(stdout)
490
+
491
+ expect(response['id']).to eq(80)
492
+ content = response['result']['content']
493
+ expect(content.first['type']).to eq('text')
494
+
495
+ begin
496
+ result_json = JSON.parse(content.first['text'])
497
+ rescue JSON::ParserError
498
+ puts "DEBUG: Failed to parse JSON. Content was: #{content.first['text']}"
499
+ raise
500
+ end
501
+ expect(result_json).to include('result' => true)
502
+ end
503
+
504
+ it 'handles error responses for invalid tool calls' do
505
+ request = {
506
+ jsonrpc: '2.0',
507
+ id: 8,
508
+ method: 'tools/call',
509
+ params: {
510
+ name: 'coverage_summary_tool',
511
+ arguments: {
512
+ path: 'nonexistent_file.rb',
513
+ root: project_root,
514
+ resultset: coverage_dir
515
+ }
516
+ }
517
+ }
518
+
519
+ result = run_mcp_json(request)
520
+ response = parse_jsonrpc_response(result[:stdout])
521
+
522
+ # MCP should return a response (not crash)
523
+ expect(response['id']).to eq(8)
524
+
525
+ # Should include error information in content or error field
526
+ if response['error']
527
+ expect(response['error']).to have_key('message')
528
+ elsif response['result']
529
+ content = response['result']['content']
530
+ text = content.first['text']
531
+ expect(text.downcase).to include('error').or include('not found')
532
+ end
533
+ end
534
+
535
+ it 'handles malformed JSON-RPC requests' do
536
+ malformed_request = "{'jsonrpc': '2.0', 'id': 999, 'method': 'invalid'}"
537
+
538
+ env = { 'RUBY_LIB' => lib_path }
539
+ result = run_mcp_input(malformed_request, env: env)
540
+
541
+ # Should handle gracefully without crashing
542
+ # May return error response or empty output
543
+ expect(result[:stderr]).not_to include('NameError')
544
+ expect(result[:stderr]).not_to include('uninitialized constant')
545
+ end
546
+
547
+ it 'respects --log-file configuration in MCP mode' do
548
+ request = {
549
+ jsonrpc: '2.0',
550
+ id: 10,
551
+ method: 'tools/call',
552
+ params: {
553
+ name: 'version_tool',
554
+ arguments: {}
555
+ }
556
+ }
557
+
558
+ result = run_mcp_json(
559
+ request,
560
+ env: default_env.merge('COV_LOUPE_OPTS' => '--log-file stderr')
561
+ )
562
+
563
+ response = parse_jsonrpc_response(result[:stdout])
564
+ expect(response).not_to be_nil
565
+ expect(response['id']).to eq(10)
566
+ end
567
+
568
+ it 'prohibits stdout logging in MCP mode' do
569
+ # Attempt to start MCP server with --log-file stdout should fail
570
+ env = {
571
+ 'RUBY_LIB' => lib_path,
572
+ 'COV_LOUPE_OPTS' => '--log-file stdout'
573
+ }
574
+
575
+ result = run_mcp_input(nil, env: env)
576
+
577
+ combined_output = result[:stdout] + result[:stderr]
578
+ expect(combined_output).to include('stdout').and include('not permitted')
579
+ expect(result[:status].exitstatus).not_to eq(0)
580
+ end
581
+
582
+ it 'handles multiple sequential requests' do
583
+ requests = [
584
+ { jsonrpc: '2.0', id: 100, method: 'tools/list' },
585
+ { jsonrpc: '2.0', id: 101, method: 'tools/call',
586
+ params: { name: 'version_tool', arguments: {} } }
587
+ ]
588
+
589
+ result = run_mcp_json_stream(requests)
590
+
591
+ # Force UTF-8 encoding to handle any binary data in the output stream
592
+ safe_stdout = result[:stdout].to_s
593
+ .encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
594
+ responses = safe_stdout.lines.map do |line|
595
+ next if line.strip.empty?
596
+
597
+ begin
598
+ parsed = JSON.parse(line)
599
+ parsed if parsed['jsonrpc'] == '2.0'
600
+ rescue JSON::ParserError
601
+ nil
602
+ end
603
+ end.compact
604
+
605
+ expect(responses.length).to be >= 1
606
+ response_ids = responses.map { |r| r['id'] }
607
+ expect(response_ids).to include(100).or include(101)
608
+ end
609
+
610
+ context 'when handling MCP protocol errors' do
611
+ it 'returns error for unknown tool name' do
612
+ request = {
613
+ jsonrpc: '2.0',
614
+ id: 200,
615
+ method: 'tools/call',
616
+ params: {
617
+ name: 'nonexistent_tool_that_does_not_exist',
618
+ arguments: {}
619
+ }
620
+ }
621
+
622
+ result = run_mcp_json(request)
623
+ response = parse_jsonrpc_response(result[:stdout])
624
+
625
+ expect(response['id']).to eq(200)
626
+ expect(response['jsonrpc']).to eq('2.0')
627
+
628
+ # MCP server should return error in result content or error field
629
+ if response['error']
630
+ # Standard JSON-RPC error format
631
+ expect(response['error']).to have_key('message')
632
+ # MCP returns "Internal error" for unknown tools
633
+ expect(response['error']['message']).to be_a(String)
634
+ expect(response['error']['message'].length).to be > 0
635
+ elsif response['result']
636
+ # MCP may wrap errors in content
637
+ content = response['result']['content']
638
+ expect(content).to be_an(Array)
639
+ text = content.first['text']
640
+ expect(text.downcase).to include('error').or include('not found')
641
+ else
642
+ raise 'Expected either error or result field in response'
643
+ end
644
+ end
645
+
646
+ it 'returns error for missing required arguments' do
647
+ request = {
648
+ jsonrpc: '2.0',
649
+ id: 201,
650
+ method: 'tools/call',
651
+ params: {
652
+ name: 'coverage_summary_tool',
653
+ arguments: {} # Missing required 'path' argument
654
+ }
655
+ }
656
+
657
+ result = run_mcp_json(request)
658
+ response = parse_jsonrpc_response(result[:stdout])
659
+
660
+ expect(response['id']).to eq(201)
661
+ expect(response['jsonrpc']).to eq('2.0')
662
+
663
+ # Should return an error about missing path
664
+ if response['error']
665
+ expect(response['error']).to have_key('message')
666
+ elsif response['result']
667
+ content = response['result']['content']
668
+ text = content.first['text']
669
+ expect(text.downcase).to include('error').or include('required').or include('path')
670
+ else
671
+ raise 'Expected either error or result field in response'
672
+ end
673
+ end
674
+
675
+ it 'handles invalid argument types gracefully' do
676
+ request = {
677
+ jsonrpc: '2.0',
678
+ id: 202,
679
+ method: 'tools/call',
680
+ params: {
681
+ name: 'coverage_summary_tool',
682
+ arguments: {
683
+ path: 12_345, # Should be string, not number
684
+ root: project_root,
685
+ resultset: coverage_dir
686
+ }
687
+ }
688
+ }
689
+
690
+ result = run_mcp_json(request)
691
+ response = parse_jsonrpc_response(result[:stdout])
692
+
693
+ expect(response['id']).to eq(202)
694
+ expect(response['jsonrpc']).to eq('2.0')
695
+
696
+ # Should handle gracefully (may coerce to string or return error)
697
+ expect(response).to have_key('result').or have_key('error')
698
+ end
699
+
700
+ it 'returns properly formatted JSON-RPC error responses' do
701
+ request = {
702
+ jsonrpc: '2.0',
703
+ id: 203,
704
+ method: 'tools/call',
705
+ params: {
706
+ name: 'coverage_summary_tool',
707
+ arguments: {
708
+ path: 'definitely_does_not_exist.rb',
709
+ root: project_root,
710
+ resultset: coverage_dir
711
+ }
712
+ }
713
+ }
714
+
715
+ result = run_mcp_json(request)
716
+ response = parse_jsonrpc_response(result[:stdout])
717
+
718
+ # Verify JSON-RPC 2.0 compliance
719
+ expect(response).to include('jsonrpc' => '2.0', 'id' => 203)
720
+
721
+ # Must have either 'result' or 'error', but not both
722
+ has_result = response.key?('result')
723
+ has_error = response.key?('error')
724
+ expect(has_result ^ has_error).to be true
725
+
726
+ # If error field exists, verify structure
727
+ if has_error
728
+ expect(response['error']).to have_key('message')
729
+ expect(response['error']['message']).to be_a(String)
730
+ end
731
+ end
732
+
733
+ it 'handles requests with missing params field' do
734
+ request = {
735
+ jsonrpc: '2.0',
736
+ id: 204,
737
+ method: 'tools/call'
738
+ # Missing params field entirely
739
+ }
740
+
741
+ result = run_mcp_json(request)
742
+
743
+ # Should not crash - either returns error or handles gracefully
744
+ expect(result[:stderr]).not_to include('NameError')
745
+ expect(result[:stderr]).not_to include('NoMethodError')
746
+
747
+ # Parse response if available
748
+ if result[:stdout] && !result[:stdout].strip.empty?
749
+ response = parse_jsonrpc_response(result[:stdout])
750
+ expect(response['jsonrpc']).to eq('2.0')
751
+ expect(response['id']).to eq(204)
752
+ end
753
+ end
754
+
755
+ it 'handles completely invalid JSON input' do
756
+ invalid_json = 'this is not JSON at all'
757
+
758
+ result = run_mcp_input(invalid_json, env: default_env)
759
+
760
+ # Should not crash with unhandled exception
761
+ combined = result[:stdout] + result[:stderr]
762
+ expect(combined).not_to include('uninitialized constant')
763
+
764
+ # May log error to stderr, but shouldn't crash
765
+ if result[:status]
766
+ # Exit code may be non-zero but shouldn't be a crash (e.g., signal)
767
+ expect(result[:status].exitstatus).to be_a(Integer)
768
+ end
769
+ end
770
+
771
+ it 'handles empty input gracefully' do
772
+ result = run_mcp_input('', env: default_env)
773
+
774
+ # Empty input should be handled without crash
775
+ expect(result[:stderr]).not_to include('NameError')
776
+ expect(result[:stderr]).not_to include('NoMethodError')
777
+ end
778
+
779
+ it 'handles partial JSON input' do
780
+ partial_json = '{"jsonrpc": "2.0", "id": 300, "method":'
781
+
782
+ result = run_mcp_input(partial_json, env: default_env)
783
+
784
+ # Should handle gracefully without crashing
785
+ expect(result[:stderr]).not_to include('uninitialized constant')
786
+ end
787
+ end
788
+ end
789
+ end