simplecov-mcp 1.0.0 → 2.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 (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 +32 -20
  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 -83
  53. data/lib/simplecov_mcp/option_normalizers.rb +39 -18
  54. data/lib/simplecov_mcp/option_parser_builder.rb +82 -65
  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 +114 -170
  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 +141 -82
  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 +99 -49
  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
@@ -2,148 +2,74 @@
2
2
 
3
3
  require 'spec_helper'
4
4
 
5
+ # Timeout for MCP server operations (increased for JRuby compatibility)
6
+ MCP_TIMEOUT = 5
7
+
5
8
  RSpec.describe 'SimpleCov MCP Integration Tests' do
6
9
  let(:project_root) { (FIXTURES_DIR / 'project1').to_s }
7
10
  let(:coverage_dir) { File.join(project_root, 'coverage') }
8
11
  let(:resultset_path) { File.join(coverage_dir, '.resultset.json') }
9
12
 
10
13
  describe 'End-to-End Coverage Model Functionality' do
11
- context 'with real coverage data and files' do
12
- it 'provides complete coverage analysis workflow' do
13
- # Initialize model with real fixture data
14
- model = SimpleCovMcp::CoverageModel.new(root: project_root, resultset: coverage_dir)
15
-
16
- # Test all_files returns real coverage data
17
- all_files = model.all_files
18
- expect(all_files).to be_an(Array)
19
- expect(all_files.length).to eq(2)
20
-
21
- # Verify file paths and coverage data structure
22
- foo_file = all_files.find { |f| f['file'].include?('foo.rb') }
23
- bar_file = all_files.find { |f| f['file'].include?('bar.rb') }
24
-
25
- expect(foo_file).to include('covered', 'total', 'percentage', 'stale')
26
- expect(bar_file).to include('covered', 'total', 'percentage', 'stale')
27
-
28
- # Verify actual coverage calculations match fixture data
29
- # foo.rb has coverage: [1, 0, nil, 2] -> 2 covered out of 3 executable = 66.67%
30
- expect(foo_file['total']).to eq(3)
31
- expect(foo_file['covered']).to eq(2)
32
- expect(foo_file['percentage']).to be_within(0.01).of(66.67)
33
-
34
- # bar.rb has coverage: [0, 0, 1] -> 1 covered out of 3 executable = 33.33%
35
- expect(bar_file['total']).to eq(3)
36
- expect(bar_file['covered']).to eq(1)
37
- expect(bar_file['percentage']).to be_within(0.01).of(33.33)
38
- end
39
-
40
- it 'provides detailed per-file analysis' do
41
- model = SimpleCovMcp::CoverageModel.new(root: project_root, resultset: coverage_dir)
42
-
43
- # Test raw coverage data
44
- raw = model.raw_for('lib/foo.rb')
45
- expect(raw['file']).to end_with('lib/foo.rb')
46
- expect(raw['lines']).to eq([1, 0, nil, 2])
47
-
48
- # Test summary calculation
49
- summary = model.summary_for('lib/foo.rb')
50
- expect(summary['file']).to end_with('lib/foo.rb')
51
- expect(summary['summary']).to include('covered' => 2, 'total' => 3)
52
- expect(summary['summary']['pct']).to be_within(0.01).of(66.67)
53
-
54
- # Test uncovered lines detection
55
- uncovered = model.uncovered_for('lib/foo.rb')
56
- expect(uncovered['file']).to end_with('lib/foo.rb')
57
- expect(uncovered['uncovered']).to eq([2]) # Line 2 has 0 hits
58
- expect(uncovered['summary']).to include('covered' => 2, 'total' => 3)
59
-
60
- # Test detailed line-by-line analysis
61
- detailed = model.detailed_for('lib/foo.rb')
62
- expect(detailed['file']).to end_with('lib/foo.rb')
63
- expect(detailed['lines']).to eq([
64
- { 'line' => 1, 'hits' => 1, 'covered' => true },
65
- { 'line' => 2, 'hits' => 0, 'covered' => false },
66
- { 'line' => 4, 'hits' => 2, 'covered' => true }
67
- ])
68
- end
69
-
70
- it 'generates properly formatted coverage tables' do
71
- model = SimpleCovMcp::CoverageModel.new(root: project_root, resultset: coverage_dir)
72
-
73
- # Test default table generation
74
- table = model.format_table
75
-
76
- # Verify table structure (Unicode box drawing)
77
- expect(table).to include('┌', '┬', '┐', '│', '├', '┼', '┤', '└', '┴', '┘')
78
-
79
- # Verify headers
80
- expect(table).to include('File', '%', 'Covered', 'Total', 'Stale')
14
+ it 'loads fixture coverage and surfaces core stats across APIs' do
15
+ model = SimpleCovMcp::CoverageModel.new(root: project_root, resultset: coverage_dir)
81
16
 
82
- # Verify file data appears
83
- expect(table).to include('lib/foo.rb', 'lib/bar.rb')
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] }
84
20
 
85
- # Verify percentages are formatted correctly
86
- expect(table).to include('66.67', '33.33')
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)
87
25
 
88
- # Verify counts summary
89
- expect(table).to include('Files: total 2')
26
+ raw = model.raw_for('lib/foo.rb')
27
+ expect(raw['lines']).to eq([1, 0, nil, 2])
90
28
 
91
- # Test sorting (ascending by default - bar.rb should be first with lower coverage)
92
- lines = table.split("\n")
93
- data_lines = lines.select { |line| line.include?('lib/') }
94
- expect(data_lines.first).to include('lib/bar.rb') # Lower coverage first
95
- expect(data_lines.last).to include('lib/foo.rb') # Higher coverage last
96
- end
29
+ summary = model.summary_for('lib/foo.rb')
30
+ expect(summary['summary']).to include('covered' => 2, 'total' => 3)
97
31
 
98
- it 'supports different sorting options' do
99
- model = SimpleCovMcp::CoverageModel.new(root: project_root, resultset: coverage_dir)
32
+ uncovered = model.uncovered_for('lib/foo.rb')
33
+ expect(uncovered['uncovered']).to eq([2])
100
34
 
101
- # Test ascending sort
102
- asc_files = model.all_files(sort_order: :ascending)
103
- expect(asc_files.first['file']).to end_with('lib/bar.rb') # Lower coverage first
104
- expect(asc_files.last['file']).to end_with('lib/foo.rb') # Higher coverage last
35
+ detailed = model.detailed_for('lib/foo.rb')
36
+ expect(detailed['lines']).to include({ 'line' => 2, 'hits' => 0, 'covered' => false })
105
37
 
106
- # Test descending sort
107
- desc_files = model.all_files(sort_order: :descending)
108
- expect(desc_files.first['file']).to end_with('lib/foo.rb') # Higher coverage first
109
- expect(desc_files.last['file']).to end_with('lib/bar.rb') # Lower coverage last
110
- end
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')
111
43
  end
112
44
  end
113
45
 
114
46
  describe 'CLI Integration with Real Coverage Data' do
115
47
  it 'executes all major CLI commands without errors' do
116
48
  # Test list command
117
- list_output = nil
118
- silence_output do |out, _err|
119
- cli = SimpleCovMcp::CoverageCLI.new
120
- cli.run(['list', '--root', project_root, '--resultset', coverage_dir])
121
- list_output = out.string
122
- end
123
-
49
+ list_output, _err, status = run_cli_with_status('--root', project_root, '--resultset',
50
+ coverage_dir, 'list')
51
+ expect(status).to eq(0)
124
52
  expect(list_output).to include('lib/foo.rb', 'lib/bar.rb')
125
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')
126
57
 
127
58
  # Test summary command
128
- summary_output = nil
129
- silence_output do |out, _err|
130
- cli = SimpleCovMcp::CoverageCLI.new
131
- cli.run(['summary', 'lib/foo.rb', '--root', project_root, '--resultset', coverage_dir])
132
- summary_output = out.string
133
- end
134
-
135
- expect(summary_output).to include('66.67%', '2/3')
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')
136
66
 
137
67
  # Test JSON output
138
- json_output = nil
139
- silence_output do |out, _err|
140
- cli = SimpleCovMcp::CoverageCLI.new
141
- cli.run([
142
- 'summary', 'lib/foo.rb', '--json', '--root', project_root, '--resultset', coverage_dir
143
- ])
144
- json_output = out.string
145
- end
146
-
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)
147
73
  json_data = JSON.parse(json_output)
148
74
  expect(json_data).to include('file', 'summary')
149
75
  expect(json_data['summary']).to include('covered' => 2, 'total' => 3)
@@ -151,23 +77,17 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
151
77
 
152
78
  it 'handles different output formats correctly' do
153
79
  # Test uncovered command with different outputs
154
- uncovered_output = nil
155
- silence_output do |out, _err|
156
- cli = SimpleCovMcp::CoverageCLI.new
157
- cli.run(['uncovered', 'lib/foo.rb', '--root', project_root, '--resultset', coverage_dir])
158
- uncovered_output = out.string
159
- end
160
-
161
- expect(uncovered_output).to match(/Uncovered lines:\s*2\b/)
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
162
85
 
163
86
  # Test detailed command
164
- detailed_output = nil
165
- silence_output do |out, _err|
166
- cli = SimpleCovMcp::CoverageCLI.new
167
- cli.run(['detailed', 'lib/foo.rb', '--root', project_root, '--resultset', coverage_dir])
168
- detailed_output = out.string
169
- end
170
-
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)
171
91
  expect(detailed_output).to include('Line', 'Hits', 'Covered')
172
92
  end
173
93
  end
@@ -188,7 +108,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
188
108
  server_context: server_context
189
109
  )
190
110
 
191
- data, item = expect_mcp_text_json(summary_response, expected_keys: ['file', 'summary'])
111
+ data, _item = expect_mcp_text_json(summary_response, expected_keys: ['file', 'summary'])
192
112
  expect(data['summary']).to include('covered' => 2, 'total' => 3)
193
113
 
194
114
  # Test raw coverage tool
@@ -199,7 +119,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
199
119
  server_context: server_context
200
120
  )
201
121
 
202
- raw_data, raw_item = expect_mcp_text_json(raw_response, expected_keys: ['file', 'lines'])
122
+ raw_data, _raw_item = expect_mcp_text_json(raw_response, expected_keys: ['file', 'lines'])
203
123
  expect(raw_data['lines']).to eq([1, 0, nil, 2])
204
124
 
205
125
  # Test all files tool
@@ -209,7 +129,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
209
129
  server_context: server_context
210
130
  )
211
131
 
212
- all_data, _ = expect_mcp_text_json(all_files_response, expected_keys: ['files', 'counts'])
132
+ all_data, = expect_mcp_text_json(all_files_response, expected_keys: ['files', 'counts'])
213
133
  expect(all_data['files'].length).to eq(2)
214
134
  expect(all_data['counts']['total']).to eq(2)
215
135
  end
@@ -222,7 +142,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
222
142
  resultset: coverage_dir,
223
143
  server_context: server_context
224
144
  )
225
- summary_data, _ = expect_mcp_text_json(summary_response)
145
+ summary_data, = expect_mcp_text_json(summary_response)
226
146
 
227
147
  # Get data from detailed tool
228
148
  detailed_response = SimpleCovMcp::Tools::CoverageDetailedTool.call(
@@ -231,7 +151,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
231
151
  resultset: coverage_dir,
232
152
  server_context: server_context
233
153
  )
234
- detailed_data, _ = expect_mcp_text_json(detailed_response)
154
+ detailed_data, = expect_mcp_text_json(detailed_response)
235
155
 
236
156
  # Verify consistency between tools
237
157
  expect(summary_data['summary']['covered']).to eq(2)
@@ -257,24 +177,13 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
257
177
  it 'handles invalid resultset paths gracefully' do
258
178
  expect do
259
179
  SimpleCovMcp::CoverageModel.new(root: project_root, resultset: '/nonexistent/path')
260
- end.to raise_error(SimpleCovMcp::CoverageDataError, /Failed to load coverage data/)
180
+ end.to raise_error(SimpleCovMcp::ResultsetNotFoundError, /Specified resultset not found/)
261
181
  end
262
182
 
263
183
  it 'provides helpful CLI error messages' do
264
- output, error, status = nil, nil, nil
265
- silence_output do |out, err|
266
- begin
267
- cli = SimpleCovMcp::CoverageCLI.new
268
- cli.run([
269
- 'summary', 'lib/nonexistent.rb', '--root', project_root, '--resultset', coverage_dir
270
- ])
271
- status = 0
272
- rescue SystemExit => e
273
- status = e.status
274
- end
275
- output = out.string
276
- error = err.string
277
- end
184
+ _output, error, status = run_cli_with_status(
185
+ '--root', project_root, '--resultset', coverage_dir, 'summary', 'lib/nonexistent.rb'
186
+ )
278
187
 
279
188
  expect(status).to eq(1)
280
189
  expect(error).to include('File error:', 'No coverage entry found')
@@ -318,7 +227,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
318
227
  let(:default_env) do
319
228
  {
320
229
  'RUBY_LIB' => lib_path,
321
- 'SIMPLECOV_MCP_OPTS' => "--root #{project_root} --resultset #{coverage_dir}"
230
+ 'SIMPLECOV_MCP_OPTS' => "--root #{project_root} --resultset #{coverage_dir} --log-file /dev/null"
322
231
  }
323
232
  end
324
233
 
@@ -332,7 +241,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
332
241
  end
333
242
 
334
243
  # Run the MCP executable with a single JSON-RPC request hash and return the captured streams.
335
- def run_mcp_json(request_hash, env: default_env, timeout: 5)
244
+ def run_mcp_json(request_hash, env: default_env, timeout: MCP_TIMEOUT)
336
245
  Spec::Support::McpRunner.call_json(
337
246
  request_hash,
338
247
  **runner_args(env: env, timeout: timeout)
@@ -340,7 +249,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
340
249
  end
341
250
 
342
251
  # Run the MCP executable with a sequence of JSON-RPC requests (one per line).
343
- def run_mcp_json_stream(request_hashes, env: default_env, timeout: 5)
252
+ def run_mcp_json_stream(request_hashes, env: default_env, timeout: MCP_TIMEOUT)
344
253
  Spec::Support::McpRunner.call_json_stream(
345
254
  request_hashes,
346
255
  **runner_args(env: env, timeout: timeout)
@@ -348,7 +257,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
348
257
  end
349
258
 
350
259
  # Run the MCP executable with a raw string payload (already encoded as needed).
351
- def run_mcp_input(input, env: default_env, timeout: 5)
260
+ def run_mcp_input(input, env: default_env, timeout: MCP_TIMEOUT)
352
261
  Spec::Support::McpRunner.call(
353
262
  input: input,
354
263
  **runner_args(env: env, timeout: timeout)
@@ -357,7 +266,9 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
357
266
 
358
267
  def parse_jsonrpc_response(output)
359
268
  # MCP server should only write JSON-RPC responses to stdout.
360
- output.lines.each do |line|
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|
361
272
  stripped = line.strip
362
273
  next if stripped.empty?
363
274
 
@@ -559,6 +470,37 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
559
470
  expect(version_text).to match(/SimpleCovMcp version: \d+\.\d+/)
560
471
  end
561
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
+
562
504
  it 'handles error responses for invalid tool calls' do
563
505
  request = {
564
506
  jsonrpc: '2.0',
@@ -594,7 +536,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
594
536
  malformed_request = "{'jsonrpc': '2.0', 'id': 999, 'method': 'invalid'}"
595
537
 
596
538
  env = { 'RUBY_LIB' => lib_path }
597
- result = run_mcp_input(malformed_request, env: env, timeout: 3)
539
+ result = run_mcp_input(malformed_request, env: env)
598
540
 
599
541
  # Should handle gracefully without crashing
600
542
  # May return error response or empty output
@@ -615,8 +557,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
615
557
 
616
558
  result = run_mcp_json(
617
559
  request,
618
- env: default_env.merge('SIMPLECOV_MCP_OPTS' => '--log-file stderr'),
619
- timeout: 3
560
+ env: default_env.merge('SIMPLECOV_MCP_OPTS' => '--log-file stderr')
620
561
  )
621
562
 
622
563
  response = parse_jsonrpc_response(result[:stdout])
@@ -631,7 +572,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
631
572
  'SIMPLECOV_MCP_OPTS' => '--log-file stdout'
632
573
  }
633
574
 
634
- result = run_mcp_input(nil, env: env, timeout: 3)
575
+ result = run_mcp_input(nil, env: env)
635
576
 
636
577
  combined_output = result[:stdout] + result[:stderr]
637
578
  expect(combined_output).to include('stdout').and include('not permitted')
@@ -645,9 +586,12 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
645
586
  params: { name: 'version_tool', arguments: {} } }
646
587
  ]
647
588
 
648
- result = run_mcp_json_stream(requests, timeout: 5)
589
+ result = run_mcp_json_stream(requests)
649
590
 
650
- responses = result[:stdout].lines.map do |line|
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|
651
595
  next if line.strip.empty?
652
596
 
653
597
  begin
@@ -663,7 +607,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
663
607
  expect(response_ids).to include(100).or include(101)
664
608
  end
665
609
 
666
- context 'MCP protocol error handling' do
610
+ context 'when handling MCP protocol errors' do
667
611
  it 'returns error for unknown tool name' do
668
612
  request = {
669
613
  jsonrpc: '2.0',
@@ -695,7 +639,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
695
639
  text = content.first['text']
696
640
  expect(text.downcase).to include('error').or include('not found')
697
641
  else
698
- fail 'Expected either error or result field in response'
642
+ raise 'Expected either error or result field in response'
699
643
  end
700
644
  end
701
645
 
@@ -724,7 +668,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
724
668
  text = content.first['text']
725
669
  expect(text.downcase).to include('error').or include('required').or include('path')
726
670
  else
727
- fail 'Expected either error or result field in response'
671
+ raise 'Expected either error or result field in response'
728
672
  end
729
673
  end
730
674
 
@@ -736,7 +680,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
736
680
  params: {
737
681
  name: 'coverage_summary_tool',
738
682
  arguments: {
739
- path: 12345, # Should be string, not number
683
+ path: 12_345, # Should be string, not number
740
684
  root: project_root,
741
685
  resultset: coverage_dir
742
686
  }
@@ -811,7 +755,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
811
755
  it 'handles completely invalid JSON input' do
812
756
  invalid_json = 'this is not JSON at all'
813
757
 
814
- result = run_mcp_input(invalid_json, env: default_env, timeout: 3)
758
+ result = run_mcp_input(invalid_json, env: default_env)
815
759
 
816
760
  # Should not crash with unhandled exception
817
761
  combined = result[:stdout] + result[:stderr]
@@ -825,7 +769,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
825
769
  end
826
770
 
827
771
  it 'handles empty input gracefully' do
828
- result = run_mcp_input('', env: default_env, timeout: 2)
772
+ result = run_mcp_input('', env: default_env)
829
773
 
830
774
  # Empty input should be handled without crash
831
775
  expect(result[:stderr]).not_to include('NameError')
@@ -835,7 +779,7 @@ RSpec.describe 'SimpleCov MCP Integration Tests' do
835
779
  it 'handles partial JSON input' do
836
780
  partial_json = '{"jsonrpc": "2.0", "id": 300, "method":'
837
781
 
838
- result = run_mcp_input(partial_json, env: default_env, timeout: 2)
782
+ result = run_mcp_input(partial_json, env: default_env)
839
783
 
840
784
  # Should handle gracefully without crashing
841
785
  expect(result[:stderr]).not_to include('uninitialized constant')
@@ -54,7 +54,7 @@ RSpec.describe 'Logging Fallback Behavior' do
54
54
  context = SimpleCovMcp.create_context(
55
55
  error_handler: SimpleCovMcp::ErrorHandlerFactory.for_mcp_server,
56
56
  log_target: '/invalid/path/that/does/not/exist.log',
57
- mode: :mcp_server
57
+ mode: :mcp
58
58
  )
59
59
 
60
60
  stderr_output = nil
@@ -115,10 +115,10 @@ RSpec.describe 'Logging Fallback Behavior' do
115
115
  expect(context.mcp_mode?).to be false
116
116
  end
117
117
 
118
- it 'correctly identifies MCP server mode' do
118
+ it 'correctly identifies MCP mode' do
119
119
  context = SimpleCovMcp.create_context(
120
120
  error_handler: SimpleCovMcp::ErrorHandlerFactory.for_mcp_server,
121
- mode: :mcp_server
121
+ mode: :mcp
122
122
  )
123
123
  expect(context.library_mode?).to be false
124
124
  expect(context.cli_mode?).to be false
@@ -5,7 +5,7 @@ require 'spec_helper'
5
5
  RSpec.describe 'MCP Server Bootstrap' do
6
6
  it 'does not crash on startup in non-TTY environments' do
7
7
  # Simulate a non-TTY environment, which should trigger MCP mode
8
- allow(STDIN).to receive(:tty?).and_return(false)
8
+ allow($stdin).to receive(:tty?).and_return(false)
9
9
 
10
10
  # The server will try to run, but we only need to ensure it gets past
11
11
  # the point where the NameError would have occurred. We can mock the
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'support/fake_mcp'
4
5
 
5
6
  RSpec.describe SimpleCovMcp::MCPServer do
6
7
  # This spec verifies the MCP server boot path without requiring the real
@@ -10,55 +11,8 @@ RSpec.describe SimpleCovMcp::MCPServer do
10
11
  # Prepare fakes for MCP server and transport
11
12
  module ::MCP; end unless defined?(::MCP)
12
13
 
13
- # Fake server captures the last created instance so we can assert on the
14
- # name/version/tools passed in by SimpleCovMcp::MCPServer. The
15
- # `last_instance` accessor is a class-level handle to the most recently
16
- # instantiated fake. Because the production code constructs the server
17
- # internally, we can't grab the instance directly; recording the most
18
- # recent instance lets the test fetch it after `run` completes.
19
- fake_server_class = Class.new do
20
- class << self
21
- # Holds the most recently created fake server instance so tests can
22
- # inspect it after the code under test performs internal construction.
23
- attr_accessor :last_instance
24
- end
25
- attr_reader :params
26
-
27
- def initialize(name:, version:, tools:)
28
- @params = { name: name, version: version, tools: tools }
29
- self.class.last_instance = self
30
- end
31
- end
32
-
33
- # Fake stdio transport records whether `open` was called and the server
34
- # it was initialized with, to confirm that the server was started. It also
35
- # exposes a `last_instance` class accessor for the same reason as above:
36
- # to retrieve the instance created during `run` so we can assert on it.
37
- fake_transport_class = Class.new do
38
- class << self
39
- # Holds the most recently created fake transport instance for later
40
- # assertions (e.g., that `open` was invoked).
41
- attr_accessor :last_instance
42
- end
43
- attr_reader :server, :opened
44
-
45
- def initialize(server)
46
- @server = server
47
- @opened = false
48
- self.class.last_instance = self
49
- end
50
-
51
- def open
52
- @opened = true
53
- end
54
-
55
- def opened?
56
- @opened
57
- end
58
- end
59
-
60
- stub_const('MCP::Server', fake_server_class)
61
- stub_const('MCP::Server::Transports::StdioTransport', fake_transport_class)
14
+ stub_const('MCP::Server', FakeMCP::Server)
15
+ stub_const('MCP::Server::Transports::StdioTransport', FakeMCP::StdioTransport)
62
16
 
63
17
  server_context = SimpleCovMcp.create_context(
64
18
  error_handler: SimpleCovMcp::ErrorHandlerFactory.for_mcp_server,
@@ -73,8 +27,8 @@ RSpec.describe SimpleCovMcp::MCPServer do
73
27
  expect(SimpleCovMcp.context).to eq(baseline_context)
74
28
 
75
29
  # Fetch the instances created during `run` via the class-level hooks.
76
- fake_server = fake_server_class.last_instance
77
- fake_transport = fake_transport_class.last_instance
30
+ fake_server = FakeMCP::Server.last_instance
31
+ fake_transport = FakeMCP::StdioTransport.last_instance
78
32
 
79
33
  expect(fake_transport).not_to be_nil
80
34
  expect(fake_transport).to be_opened
@@ -83,7 +37,70 @@ RSpec.describe SimpleCovMcp::MCPServer do
83
37
  expect(fake_server.params[:name]).to eq('simplecov-mcp')
84
38
  # Ensure expected tools are registered
85
39
  tool_names = fake_server.params[:tools].map { |t| t.name.split('::').last }
86
- expect(tool_names).to include('AllFilesCoverageTool', 'CoverageDetailedTool',
87
- 'CoverageRawTool', 'CoverageSummaryTool', 'UncoveredLinesTool', 'HelpTool')
40
+ expect(tool_names).to include(
41
+ 'AllFilesCoverageTool',
42
+ 'CoverageDetailedTool',
43
+ 'CoverageRawTool',
44
+ 'CoverageSummaryTool',
45
+ 'CoverageTotalsTool',
46
+ 'UncoveredLinesTool',
47
+ 'CoverageTableTool',
48
+ 'HelpTool',
49
+ 'VersionTool'
50
+ )
51
+ end
52
+
53
+ describe 'TOOLSET and TOOL_GUIDE consistency' do
54
+ it 'includes all tools documented in HelpTool TOOL_GUIDE' do
55
+ # Get tool classes from TOOLSET
56
+ toolset_classes = described_class::TOOLSET
57
+
58
+ # Get tool classes from TOOL_GUIDE
59
+ tool_guide_classes = SimpleCovMcp::Tools::HelpTool::TOOL_GUIDE.map { |guide| guide[:tool] }
60
+
61
+ # Every tool in TOOL_GUIDE should be in TOOLSET
62
+ tool_guide_classes.each do |tool_class|
63
+ expect(toolset_classes).to include(tool_class),
64
+ "Expected TOOLSET to include #{tool_class.name}, but it was missing. " \
65
+ 'Add it to MCPServer::TOOLSET or remove from HelpTool::TOOL_GUIDE.'
66
+ end
67
+ end
68
+
69
+ it 'has corresponding TOOL_GUIDE entry for all tools (except HelpTool itself)' do
70
+ toolset_classes = described_class::TOOLSET
71
+ tool_guide_classes = SimpleCovMcp::Tools::HelpTool::TOOL_GUIDE.map { |guide| guide[:tool] }
72
+
73
+ # Every tool in TOOLSET should be in TOOL_GUIDE (except HelpTool which documents itself)
74
+ toolset_classes.each do |tool_class|
75
+ # HelpTool doesn't need an entry about itself
76
+ next if tool_class == SimpleCovMcp::Tools::HelpTool
77
+
78
+ expect(tool_guide_classes).to include(tool_class),
79
+ "Expected TOOL_GUIDE to document #{tool_class.name}, but it was missing. " \
80
+ 'Add documentation for this tool to HelpTool::TOOL_GUIDE.'
81
+ end
82
+ end
83
+
84
+ it 'registers the expected number of tools' do
85
+ expect(described_class::TOOLSET.length).to eq(10)
86
+ end
87
+
88
+ it 'registers all tool classes defined in SimpleCovMcp::Tools module' do
89
+ # This test catches the bug where a tool file is created, required in
90
+ # simplecov_mcp.rb, but not added to MCPServer::TOOLSET.
91
+ #
92
+ # Get all classes in the Tools module that inherit from BaseTool
93
+ tool_classes = SimpleCovMcp::Tools.constants
94
+ .map { |const_name| SimpleCovMcp::Tools.const_get(const_name) }
95
+ .select { |const| const.is_a?(Class) && const < SimpleCovMcp::BaseTool }
96
+
97
+ toolset_classes = described_class::TOOLSET
98
+
99
+ tool_classes.each do |tool_class|
100
+ expect(toolset_classes).to include(tool_class),
101
+ "Expected TOOLSET to include #{tool_class.name}, but it was missing. " \
102
+ 'The tool class exists in SimpleCovMcp::Tools but is not registered in MCPServer::TOOLSET.'
103
+ end
104
+ end
88
105
  end
89
106
  end