simplecov-mcp 0.3.0 → 1.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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +173 -356
  4. data/docs/ADVANCED_USAGE.md +967 -0
  5. data/docs/ARCHITECTURE.md +79 -0
  6. data/docs/BRANCH_ONLY_COVERAGE.md +81 -0
  7. data/docs/CLI_USAGE.md +637 -0
  8. data/docs/DEVELOPMENT.md +82 -0
  9. data/docs/ERROR_HANDLING.md +93 -0
  10. data/docs/EXAMPLES.md +430 -0
  11. data/docs/INSTALLATION.md +352 -0
  12. data/docs/LIBRARY_API.md +635 -0
  13. data/docs/MCP_INTEGRATION.md +488 -0
  14. data/docs/TROUBLESHOOTING.md +276 -0
  15. data/docs/arch-decisions/001-x-arch-decision.md +93 -0
  16. data/docs/arch-decisions/002-x-arch-decision.md +157 -0
  17. data/docs/arch-decisions/003-x-arch-decision.md +163 -0
  18. data/docs/arch-decisions/004-x-arch-decision.md +199 -0
  19. data/docs/arch-decisions/005-x-arch-decision.md +187 -0
  20. data/docs/arch-decisions/README.md +60 -0
  21. data/docs/presentations/simplecov-mcp-presentation.md +249 -0
  22. data/exe/simplecov-mcp +4 -4
  23. data/lib/simplecov_mcp/app_context.rb +26 -0
  24. data/lib/simplecov_mcp/base_tool.rb +74 -0
  25. data/lib/simplecov_mcp/cli.rb +234 -0
  26. data/lib/simplecov_mcp/cli_config.rb +56 -0
  27. data/lib/simplecov_mcp/commands/base_command.rb +78 -0
  28. data/lib/simplecov_mcp/commands/command_factory.rb +39 -0
  29. data/lib/simplecov_mcp/commands/detailed_command.rb +24 -0
  30. data/lib/simplecov_mcp/commands/list_command.rb +13 -0
  31. data/lib/simplecov_mcp/commands/raw_command.rb +22 -0
  32. data/lib/simplecov_mcp/commands/summary_command.rb +24 -0
  33. data/lib/simplecov_mcp/commands/uncovered_command.rb +26 -0
  34. data/lib/simplecov_mcp/commands/version_command.rb +18 -0
  35. data/lib/simplecov_mcp/constants.rb +22 -0
  36. data/lib/simplecov_mcp/error_handler.rb +124 -0
  37. data/lib/simplecov_mcp/error_handler_factory.rb +31 -0
  38. data/lib/simplecov_mcp/errors.rb +179 -0
  39. data/lib/simplecov_mcp/formatters/source_formatter.rb +148 -0
  40. data/lib/simplecov_mcp/mcp_server.rb +40 -0
  41. data/lib/simplecov_mcp/mode_detector.rb +55 -0
  42. data/lib/simplecov_mcp/model.rb +300 -0
  43. data/lib/simplecov_mcp/option_normalizers.rb +92 -0
  44. data/lib/simplecov_mcp/option_parser_builder.rb +134 -0
  45. data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +50 -0
  46. data/lib/simplecov_mcp/option_parsers/error_helper.rb +109 -0
  47. data/lib/simplecov_mcp/path_relativizer.rb +61 -0
  48. data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +44 -0
  49. data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +16 -0
  50. data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +16 -0
  51. data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +16 -0
  52. data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +16 -0
  53. data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +52 -0
  54. data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +126 -0
  55. data/lib/simplecov_mcp/resolvers/resolver_factory.rb +28 -0
  56. data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +78 -0
  57. data/lib/simplecov_mcp/resultset_loader.rb +136 -0
  58. data/lib/simplecov_mcp/staleness_checker.rb +243 -0
  59. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/all_files_coverage_tool.rb +31 -13
  60. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_detailed_tool.rb +7 -7
  61. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_raw_tool.rb +7 -7
  62. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_summary_tool.rb +7 -7
  63. data/lib/simplecov_mcp/tools/coverage_table_tool.rb +90 -0
  64. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/help_tool.rb +13 -4
  65. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/uncovered_lines_tool.rb +7 -7
  66. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/version_tool.rb +11 -3
  67. data/lib/simplecov_mcp/util.rb +82 -0
  68. data/lib/{simple_cov_mcp → simplecov_mcp}/version.rb +1 -1
  69. data/lib/simplecov_mcp.rb +144 -2
  70. data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
  71. data/spec/TIMESTAMPS.md +48 -0
  72. data/spec/all_files_coverage_tool_spec.rb +29 -25
  73. data/spec/base_tool_spec.rb +11 -10
  74. data/spec/cli/show_default_report_spec.rb +33 -0
  75. data/spec/cli_config_spec.rb +137 -0
  76. data/spec/cli_enumerated_options_spec.rb +68 -0
  77. data/spec/cli_error_spec.rb +105 -47
  78. data/spec/cli_source_spec.rb +82 -23
  79. data/spec/cli_spec.rb +140 -5
  80. data/spec/cli_success_predicate_spec.rb +141 -0
  81. data/spec/cli_table_spec.rb +1 -1
  82. data/spec/cli_usage_spec.rb +10 -26
  83. data/spec/commands/base_command_spec.rb +187 -0
  84. data/spec/commands/command_factory_spec.rb +72 -0
  85. data/spec/commands/detailed_command_spec.rb +48 -0
  86. data/spec/commands/raw_command_spec.rb +46 -0
  87. data/spec/commands/summary_command_spec.rb +47 -0
  88. data/spec/commands/uncovered_command_spec.rb +49 -0
  89. data/spec/constants_spec.rb +61 -0
  90. data/spec/coverage_table_tool_spec.rb +17 -33
  91. data/spec/error_handler_spec.rb +22 -13
  92. data/spec/error_mode_spec.rb +143 -0
  93. data/spec/errors_edge_cases_spec.rb +239 -0
  94. data/spec/errors_stale_spec.rb +2 -2
  95. data/spec/file_based_mcp_tools_spec.rb +99 -0
  96. data/spec/fixtures/project1/lib/bar.rb +0 -1
  97. data/spec/fixtures/project1/lib/foo.rb +0 -1
  98. data/spec/help_tool_spec.rb +11 -17
  99. data/spec/integration_spec.rb +845 -0
  100. data/spec/logging_fallback_spec.rb +128 -0
  101. data/spec/mcp_logging_spec.rb +44 -0
  102. data/spec/mcp_server_integration_spec.rb +23 -0
  103. data/spec/mcp_server_spec.rb +15 -4
  104. data/spec/mode_detector_spec.rb +148 -0
  105. data/spec/model_error_handling_spec.rb +210 -0
  106. data/spec/model_staleness_spec.rb +40 -10
  107. data/spec/option_normalizers_spec.rb +204 -0
  108. data/spec/option_parsers/env_options_parser_spec.rb +233 -0
  109. data/spec/option_parsers/error_helper_spec.rb +222 -0
  110. data/spec/path_relativizer_spec.rb +83 -0
  111. data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
  112. data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
  113. data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
  114. data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
  115. data/spec/presenters/project_coverage_presenter_spec.rb +86 -0
  116. data/spec/resolvers/coverage_line_resolver_spec.rb +57 -0
  117. data/spec/resolvers/resolver_factory_spec.rb +61 -0
  118. data/spec/resolvers/resultset_path_resolver_spec.rb +55 -0
  119. data/spec/resultset_loader_spec.rb +167 -0
  120. data/spec/shared_examples/README.md +115 -0
  121. data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
  122. data/spec/shared_examples/file_based_mcp_tools.rb +174 -0
  123. data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
  124. data/spec/simple_cov_mcp_module_spec.rb +16 -0
  125. data/spec/simplecov_mcp_model_spec.rb +340 -9
  126. data/spec/simplecov_mcp_opts_spec.rb +182 -0
  127. data/spec/spec_helper.rb +147 -4
  128. data/spec/staleness_checker_spec.rb +373 -0
  129. data/spec/staleness_more_spec.rb +16 -13
  130. data/spec/support/mcp_runner.rb +64 -0
  131. data/spec/tools_error_handling_spec.rb +144 -0
  132. data/spec/util_spec.rb +109 -34
  133. data/spec/version_spec.rb +117 -9
  134. data/spec/version_tool_spec.rb +131 -10
  135. metadata +120 -63
  136. data/lib/simple_cov/mcp.rb +0 -9
  137. data/lib/simple_cov_mcp/base_tool.rb +0 -70
  138. data/lib/simple_cov_mcp/cli.rb +0 -390
  139. data/lib/simple_cov_mcp/error_handler.rb +0 -131
  140. data/lib/simple_cov_mcp/error_handler_factory.rb +0 -38
  141. data/lib/simple_cov_mcp/errors.rb +0 -176
  142. data/lib/simple_cov_mcp/mcp_server.rb +0 -30
  143. data/lib/simple_cov_mcp/model.rb +0 -104
  144. data/lib/simple_cov_mcp/staleness_checker.rb +0 -125
  145. data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +0 -61
  146. data/lib/simple_cov_mcp/util.rb +0 -122
  147. data/lib/simple_cov_mcp.rb +0 -102
  148. data/spec/coverage_detailed_tool_spec.rb +0 -36
  149. data/spec/coverage_raw_tool_spec.rb +0 -32
  150. data/spec/coverage_summary_tool_spec.rb +0 -39
  151. data/spec/legacy_shim_spec.rb +0 -13
  152. data/spec/uncovered_lines_tool_spec.rb +0 -33
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe SimpleCovMcp::ResultsetLoader do
7
+ describe '.load' do
8
+ it 'parses a single suite and returns coverage map and timestamp' do
9
+ Dir.mktmpdir do |dir|
10
+ resultset_path = File.join(dir, '.resultset.json')
11
+ coverage = {
12
+ File.join(dir, 'lib', 'foo.rb') => { 'lines' => [1, 0, nil, 2] }
13
+ }
14
+ data = {
15
+ 'SuiteA' => {
16
+ 'timestamp' => 123,
17
+ 'coverage' => coverage
18
+ }
19
+ }
20
+ File.write(resultset_path, JSON.generate(data))
21
+
22
+ result = described_class.load(resultset_path: resultset_path)
23
+
24
+ expect(result.coverage_map).to eq(coverage)
25
+ expect(result.timestamp).to eq(123)
26
+ expect(result.suite_names).to eq(['SuiteA'])
27
+ end
28
+ end
29
+
30
+ it 'merges multiple suites and combines coverage' do
31
+ Dir.mktmpdir do |dir|
32
+ resultset_path = File.join(dir, '.resultset.json')
33
+ foo_path = File.join(dir, 'lib', 'foo.rb')
34
+ bar_path = File.join(dir, 'lib', 'bar.rb')
35
+
36
+ data = {
37
+ 'RSpec' => {
38
+ 'timestamp' => 100,
39
+ 'coverage' => {
40
+ foo_path => { 'lines' => [1, 0, nil, 0] }
41
+ }
42
+ },
43
+ 'Cucumber' => {
44
+ 'timestamp' => 200,
45
+ 'coverage' => {
46
+ foo_path => { 'lines' => [0, 3, nil, 1] },
47
+ bar_path => { 'lines' => [0, 1, 1] }
48
+ }
49
+ }
50
+ }
51
+ File.write(resultset_path, JSON.generate(data))
52
+
53
+ result = described_class.load(resultset_path: resultset_path)
54
+ expect(result.coverage_map[foo_path]['lines']).to eq([1, 3, nil, 1])
55
+ expect(result.coverage_map[bar_path]['lines']).to eq([0, 1, 1])
56
+ expect(result.timestamp).to eq(200)
57
+ expect(result.suite_names).to contain_exactly('RSpec', 'Cucumber')
58
+ end
59
+ end
60
+
61
+ it 'adapts legacy array coverage entries' do
62
+ Dir.mktmpdir do |dir|
63
+ resultset_path = File.join(dir, '.resultset.json')
64
+ foo_path = File.join(dir, 'lib', 'foo.rb')
65
+ data = {
66
+ 'SuiteA' => {
67
+ 'timestamp' => 50,
68
+ 'coverage' => {
69
+ foo_path => [1, 0, nil, 2]
70
+ }
71
+ }
72
+ }
73
+ File.write(resultset_path, JSON.generate(data))
74
+
75
+ result = described_class.load(resultset_path: resultset_path)
76
+ expect(result.coverage_map[foo_path]).to eq('lines' => [1, 0, nil, 2])
77
+ end
78
+ end
79
+
80
+ it 'raises CoverageDataError when no suites are present' do
81
+ Dir.mktmpdir do |dir|
82
+ resultset_path = File.join(dir, '.resultset.json')
83
+ File.write(resultset_path, '{}')
84
+
85
+ expect do
86
+ described_class.load(resultset_path: resultset_path)
87
+ end.to raise_error(SimpleCovMcp::CoverageDataError, /No test suite/)
88
+ end
89
+ end
90
+ end
91
+
92
+ describe 'SimpleCov loading and logging' do
93
+ it 'raises CoverageDataError when SimpleCov cannot be required' do
94
+ singleton = class << described_class; self; end
95
+ singleton.send(:define_method, :require) do |name|
96
+ raise LoadError if name == 'simplecov'
97
+
98
+ Kernel.require(name)
99
+ end
100
+
101
+ expect do
102
+ described_class.send(:require_simplecov_for_merge!, '/tmp/resultset.json')
103
+ end.to raise_error(SimpleCovMcp::CoverageDataError, /Install simplecov/)
104
+ ensure
105
+ if singleton.method_defined?(:require)
106
+ singleton.send(:remove_method, :require)
107
+ end
108
+ end
109
+
110
+ it 'logs duplicate suite names when merging coverage' do
111
+ suites = [
112
+ described_class::SuiteEntry.new(name: 'RSpec', coverage: {}, timestamp: 0),
113
+ described_class::SuiteEntry.new(name: 'RSpec', coverage: {}, timestamp: 0),
114
+ described_class::SuiteEntry.new(name: 'Cucumber', coverage: {}, timestamp: 0)
115
+ ]
116
+
117
+ expect(SimpleCovMcp::CovUtil).to receive(:log)
118
+ .with(include('Merging duplicate coverage suites for RSpec'))
119
+ described_class.send(:log_duplicate_suite_names, suites)
120
+ end
121
+ end
122
+
123
+ describe 'timestamp normalization' do
124
+ it 'handles float timestamps' do
125
+ value = described_class.send(:normalize_coverage_timestamp, 123.9, nil)
126
+ expect(value).to eq(123)
127
+ end
128
+
129
+ it 'handles Time objects' do
130
+ time = Time.at(456)
131
+ value = described_class.send(:normalize_coverage_timestamp, time, nil)
132
+ expect(value).to eq(456)
133
+ end
134
+
135
+ it 'parses numeric string timestamps' do
136
+ value = described_class.send(:normalize_coverage_timestamp, '789.42', nil)
137
+ expect(value).to eq(789)
138
+ end
139
+
140
+ it 'falls back to created_at when timestamp missing' do
141
+ value = described_class.send(:normalize_coverage_timestamp, nil, 321)
142
+ expect(value).to eq(321)
143
+ end
144
+
145
+ it 'logs warning and returns zero for invalid timestamp strings' do
146
+ messages = []
147
+ allow(SimpleCovMcp::CovUtil).to receive(:log) { |msg| messages << msg }
148
+
149
+ value = described_class.send(:normalize_coverage_timestamp, 'not-a-timestamp', nil)
150
+
151
+ expect(value).to eq(0)
152
+ expect(messages.join).to include('Coverage resultset timestamp could not be parsed')
153
+ expect(messages.join).to include('not-a-timestamp')
154
+ end
155
+
156
+ it 'logs warning and returns zero for unsupported types' do
157
+ messages = []
158
+ allow(SimpleCovMcp::CovUtil).to receive(:log) { |msg| messages << msg }
159
+
160
+ value = described_class.send(:normalize_coverage_timestamp, [:invalid], nil)
161
+
162
+ expect(value).to eq(0)
163
+ expect(messages.join).to include('Coverage resultset timestamp could not be parsed')
164
+ expect(messages.join).to include('[:invalid]')
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,115 @@
1
+ # Shared Examples for MCP Tools
2
+
3
+ This directory contains reusable test patterns for SimpleCov MCP tools.
4
+
5
+ ## File-Based MCP Tools
6
+
7
+ The `file_based_mcp_tools.rb` shared example provides parameterized testing for MCP tools that follow the same pattern:
8
+
9
+ - Take a `path` parameter (file to analyze)
10
+ - Call a specific method on `CoverageModel`
11
+ - Return JSON resource with predictable structure
12
+ - Have consistent output filenames
13
+
14
+ ### Usage
15
+
16
+ Instead of creating separate spec files for each similar tool, add your tool to the `FILE_BASED_TOOL_CONFIGS` hash:
17
+
18
+ ```ruby
19
+ # In spec/shared_examples/file_based_mcp_tools.rb
20
+ your_tool: {
21
+ tool_class: SimpleCovMcp::Tools::YourTool,
22
+ model_method: :your_method,
23
+ expected_keys: ['file', 'your_data'],
24
+ output_filename: 'your_tool.json',
25
+ description: 'your tool data',
26
+ mock_data: {
27
+ 'file' => '/abs/path/lib/foo.rb',
28
+ 'your_data' => { 'key' => 'value' }
29
+ },
30
+ additional_validations: ->(data, item) {
31
+ expect(data['your_data']).to include('key')
32
+ }
33
+ }
34
+ ```
35
+
36
+ The parameterized test will automatically:
37
+ - ✅ Test basic MCP resource structure
38
+ - ✅ Verify expected JSON keys are present
39
+ - ✅ Check correct output filename
40
+ - ✅ Run tool-specific validations
41
+ - ✅ Test parameter consistency across tools
42
+ - ✅ Validate JSON structure consistency
43
+
44
+ ### Benefits vs Individual Spec Files
45
+
46
+ #### Before (Individual Files)
47
+ ```ruby
48
+ # spec/your_tool_spec.rb - 25+ lines
49
+ RSpec.describe SimpleCovMcp::Tools::YourTool do
50
+ let(:server_context) { instance_double('ServerContext').as_null_object }
51
+
52
+ before do
53
+ setup_mcp_response_stub
54
+ model = instance_double(SimpleCovMcp::CoverageModel)
55
+ allow(SimpleCovMcp::CoverageModel).to receive(:new).and_return(model)
56
+ allow(model).to receive(:your_method).with('lib/foo.rb').and_return({
57
+ 'file' => '/abs/path/lib/foo.rb',
58
+ 'your_data' => { 'key' => 'value' }
59
+ })
60
+ end
61
+
62
+ subject { described_class.call(path: 'lib/foo.rb', server_context: server_context) }
63
+
64
+ it_behaves_like 'an MCP tool that returns JSON resource'
65
+
66
+ it 'returns your tool data with expected structure' do
67
+ response = subject
68
+ data, item = expect_mcp_json_resource(response, expected_keys: ['file', 'your_data'])
69
+
70
+ expect(item['resource']['name']).to eq('your_tool.json')
71
+ expect(data['your_data']).to include('key')
72
+ end
73
+ end
74
+ ```
75
+
76
+ #### After (Parameterized)
77
+ ```ruby
78
+ # Just add to FILE_BASED_TOOL_CONFIGS - 8 lines
79
+ your_tool: {
80
+ tool_class: SimpleCovMcp::Tools::YourTool,
81
+ model_method: :your_method,
82
+ expected_keys: ['file', 'your_data'],
83
+ output_filename: 'your_tool.json',
84
+ description: 'your tool data',
85
+ mock_data: { 'file' => '/abs/path/lib/foo.rb', 'your_data' => { 'key' => 'value' } },
86
+ additional_validations: ->(data, item) { expect(data['your_data']).to include('key') }
87
+ }
88
+ ```
89
+
90
+ ### Additional Benefits
91
+
92
+ 1. **Cross-tool consistency testing**: Automatically tests that all tools handle parameters consistently
93
+ 2. **Structural validation**: Ensures all tools return properly formed MCP resources
94
+ 3. **Reduced maintenance**: Bug fixes and improvements benefit all tools at once
95
+ 4. **Better coverage**: Gets consistency tests you wouldn't write individually
96
+ 5. **Enforces patterns**: Encourages consistent tool design
97
+
98
+ ### When NOT to Use This
99
+
100
+ Don't use the parameterized approach for tools that:
101
+ - Don't follow the file-based pattern (e.g., `AllFilesCoverageTool`, `VersionTool`)
102
+ - Have significantly different parameter signatures
103
+ - Need extensive tool-specific testing that doesn't fit the pattern
104
+ - Are prototypes or experimental tools
105
+
106
+ For these cases, create individual spec files as needed.
107
+
108
+ ### Current Tools Using This Pattern
109
+
110
+ - ✅ `CoverageSummaryTool` - File summary data
111
+ - ✅ `CoverageRawTool` - Raw coverage arrays
112
+ - ✅ `UncoveredLinesTool` - Uncovered line numbers
113
+ - ✅ `CoverageDetailedTool` - Line-by-line coverage details
114
+
115
+ All tested with 13 shared tests plus 6 tool-specific tests = 19 total tests for 4 tools.
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.shared_examples 'a coverage presenter' do |config|
4
+ subject(:presenter) { described_class.new(model: model, path: config.fetch(:path, 'lib/foo.rb')) }
5
+
6
+ let(:model) { instance_double(SimpleCovMcp::CoverageModel) }
7
+ let(:raw_payload) { config.fetch(:payload) }
8
+ let(:stale_value) { config.fetch(:stale) }
9
+ let(:relative_path) { config.fetch(:relative_path, 'lib/foo.rb') }
10
+
11
+ before do
12
+ allow(model).to receive(config.fetch(:model_method)).with(config.fetch(:path,
13
+ 'lib/foo.rb')).and_return(raw_payload)
14
+ allow(model).to receive(:staleness_for).with(config.fetch(:path,
15
+ 'lib/foo.rb')).and_return(stale_value)
16
+ allow(model).to receive(:relativize) do |payload|
17
+ payload.merge('file' => relative_path)
18
+ end
19
+ end
20
+
21
+ describe '#absolute_payload' do
22
+ it 'returns data with stale metadata' do
23
+ result = presenter.absolute_payload
24
+
25
+ expect(result).to include('file' => raw_payload['file'])
26
+ Array(config.fetch(:expected_keys)).each do |key|
27
+ expect(result).to include(key => raw_payload[key])
28
+ end
29
+ expect(result['stale']).to eq(stale_value)
30
+ end
31
+
32
+ it 'does not mutate the underlying model data' do
33
+ presenter.absolute_payload
34
+ expect(raw_payload).not_to have_key('stale')
35
+ end
36
+ end
37
+
38
+ describe '#relativized_payload' do
39
+ it 'relativizes the payload once data is loaded' do
40
+ result = presenter.relativized_payload
41
+ expect(result['file']).to eq(relative_path)
42
+ expect(result['stale']).to eq(stale_value)
43
+ end
44
+
45
+ it 'only fetches model data once across calls' do
46
+ presenter.absolute_payload
47
+ presenter.relativized_payload
48
+ expect(model).to have_received(config.fetch(:model_method)).once
49
+ expect(model).to have_received(:staleness_for).once
50
+ end
51
+ end
52
+
53
+ describe '#relative_path' do
54
+ it 'returns the relativized path' do
55
+ expect(presenter.relative_path).to eq(relative_path)
56
+ end
57
+ end
58
+
59
+ describe '#stale' do
60
+ it 'returns the cached staleness flag' do
61
+ expect(presenter.stale).to eq(stale_value)
62
+ presenter.relativized_payload
63
+ expect(presenter.stale).to eq(stale_value)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ # Shared examples for file-based MCP tools that follow the same pattern:
6
+ # - Take a path parameter
7
+ # - Call a specific method on CoverageModel
8
+ # - Return JSON resource with consistent structure
9
+ # - Have predictable output filename
10
+
11
+ RSpec.shared_examples 'a file-based MCP tool' do |config|
12
+ let(:server_context) { instance_double('ServerContext').as_null_object }
13
+ let(:tool_class) { config[:tool_class] }
14
+ let(:model_method) { config[:model_method] }
15
+ let(:expected_keys) { config[:expected_keys] }
16
+ let(:output_filename) { config[:output_filename] }
17
+ let(:mock_data) { config[:mock_data] }
18
+ let(:additional_validations) { config[:additional_validations] }
19
+
20
+ before do
21
+ setup_mcp_response_stub
22
+ model = instance_double(SimpleCovMcp::CoverageModel)
23
+ allow(SimpleCovMcp::CoverageModel).to receive(:new).and_return(model)
24
+ allow(model).to receive(model_method).with('lib/foo.rb').and_return(mock_data)
25
+ relativizer = SimpleCovMcp::PathRelativizer.new(
26
+ root: '/abs/path',
27
+ scalar_keys: %w[file file_path],
28
+ array_keys: %w[newer_files missing_files deleted_files]
29
+ )
30
+ allow(model).to receive(:relativize) { |payload| relativizer.relativize(payload) }
31
+ allow(model).to receive(:staleness_for).with('lib/foo.rb').and_return(false)
32
+ end
33
+
34
+ subject { tool_class.call(path: 'lib/foo.rb', server_context: server_context) }
35
+
36
+ it_behaves_like 'an MCP tool that returns text JSON'
37
+
38
+ it "returns #{config[:description]} with expected structure" do
39
+ response = subject
40
+ data, item = expect_mcp_text_json(response, expected_keys: expected_keys)
41
+
42
+ if data.is_a?(Hash) && data.key?('file')
43
+ expect(data['file']).to eq('lib/foo.rb')
44
+ end
45
+
46
+ expect(data).to have_key('stale')
47
+ expect(data['stale']).to eq(false)
48
+
49
+ # Run tool-specific validations if provided
50
+ if additional_validations
51
+ instance_exec(data, item, &additional_validations)
52
+ end
53
+ end
54
+
55
+ # Generate tool-specific examples dynamically
56
+ tool_specific_examples = config[:tool_specific_examples] || {}
57
+ tool_specific_examples.each do |example_name, example_block|
58
+ it example_name do
59
+ instance_exec(config, &example_block)
60
+ end
61
+ end
62
+ end
63
+
64
+ # Configuration data for each file-based MCP tool
65
+ FILE_BASED_TOOL_CONFIGS = {
66
+ summary: {
67
+ tool_class: SimpleCovMcp::Tools::CoverageSummaryTool,
68
+ model_method: :summary_for,
69
+ expected_keys: ['file', 'summary', 'stale'],
70
+ output_filename: 'coverage_summary.json',
71
+ description: 'coverage summary data',
72
+ mock_data: {
73
+ 'file' => '/abs/path/lib/foo.rb',
74
+ 'summary' => { 'covered' => 10, 'total' => 12, 'pct' => 83.33 }
75
+ },
76
+ additional_validations: ->(data, item) {
77
+ expect(data['summary']).to include('covered', 'total', 'pct')
78
+ },
79
+ tool_specific_examples: {
80
+ 'includes percentage in summary data' => ->(config) {
81
+ model = instance_double(SimpleCovMcp::CoverageModel)
82
+ allow(SimpleCovMcp::CoverageModel).to receive(:new).and_return(model)
83
+ allow(model).to receive(:summary_for).and_return(config[:mock_data])
84
+ allow(model).to receive(:staleness_for).and_return(false)
85
+ relativizer = SimpleCovMcp::PathRelativizer.new(
86
+ root: '/abs/path',
87
+ scalar_keys: %w[file file_path],
88
+ array_keys: %w[newer_files missing_files deleted_files]
89
+ )
90
+ allow(model).to receive(:relativize) { |payload| relativizer.relativize(payload) }
91
+ setup_mcp_response_stub
92
+
93
+ response = config[:tool_class].call(path: 'lib/foo.rb',
94
+ server_context: instance_double('ServerContext').as_null_object)
95
+ data, _ = expect_mcp_text_json(response)
96
+
97
+ expect(data['summary']['pct']).to be_a(Float)
98
+ }
99
+ }
100
+ },
101
+
102
+ raw: {
103
+ tool_class: SimpleCovMcp::Tools::CoverageRawTool,
104
+ model_method: :raw_for,
105
+ expected_keys: ['file', 'lines', 'stale'],
106
+ output_filename: 'coverage_raw.json',
107
+ description: 'raw coverage data',
108
+ mock_data: {
109
+ 'file' => '/abs/path/lib/foo.rb',
110
+ 'lines' => [nil, 1, 0]
111
+ },
112
+ additional_validations: ->(data, _item) {
113
+ expect(data['lines']).to be_an(Array)
114
+ }
115
+ },
116
+
117
+ uncovered: {
118
+ tool_class: SimpleCovMcp::Tools::UncoveredLinesTool,
119
+ model_method: :uncovered_for,
120
+ expected_keys: ['file', 'uncovered', 'summary', 'stale'],
121
+ output_filename: 'uncovered_lines.json',
122
+ description: 'uncovered lines data',
123
+ mock_data: {
124
+ 'file' => '/abs/path/lib/foo.rb',
125
+ 'uncovered' => [5, 9, 12],
126
+ 'summary' => { 'covered' => 10, 'total' => 12, 'pct' => 83.33 }
127
+ },
128
+ additional_validations: ->(data, item) {
129
+ expect(data['uncovered']).to eq([5, 9, 12])
130
+ },
131
+ tool_specific_examples: {
132
+ 'includes both uncovered lines and summary' => ->(config) {
133
+ model = instance_double(SimpleCovMcp::CoverageModel)
134
+ allow(SimpleCovMcp::CoverageModel).to receive(:new).and_return(model)
135
+ allow(model).to receive(:uncovered_for).and_return(config[:mock_data])
136
+ allow(model).to receive(:staleness_for).and_return(false)
137
+ relativizer = SimpleCovMcp::PathRelativizer.new(
138
+ root: '/abs/path',
139
+ scalar_keys: %w[file file_path],
140
+ array_keys: %w[newer_files missing_files deleted_files]
141
+ )
142
+ allow(model).to receive(:relativize) { |payload| relativizer.relativize(payload) }
143
+ setup_mcp_response_stub
144
+
145
+ response = config[:tool_class].call(path: 'lib/foo.rb',
146
+ server_context: instance_double('ServerContext').as_null_object)
147
+ data, _ = expect_mcp_text_json(response)
148
+
149
+ expect(data['uncovered']).to be_an(Array)
150
+ expect(data['summary']).to include('covered', 'total', 'pct')
151
+ }
152
+ }
153
+ },
154
+
155
+ detailed: {
156
+ tool_class: SimpleCovMcp::Tools::CoverageDetailedTool,
157
+ model_method: :detailed_for,
158
+ expected_keys: ['file', 'lines', 'summary', 'stale'],
159
+ output_filename: 'coverage_detailed.json',
160
+ description: 'detailed coverage data',
161
+ mock_data: {
162
+ 'file' => '/abs/path/lib/foo.rb',
163
+ 'lines' => [
164
+ { 'line' => 1, 'hits' => 1, 'covered' => true },
165
+ { 'line' => 2, 'hits' => 0, 'covered' => false }
166
+ ],
167
+ 'summary' => { 'covered' => 1, 'total' => 2, 'pct' => 50.0 }
168
+ },
169
+ additional_validations: ->(data, item) {
170
+ expect(data['lines']).to be_an(Array)
171
+ expect(data['lines'].first).to include('line', 'hits', 'covered')
172
+ }
173
+ }
174
+ }.freeze
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.shared_examples 'an MCP tool that returns text JSON' do
6
+ let(:server_context) { instance_double('ServerContext').as_null_object }
7
+
8
+ before do
9
+ setup_mcp_response_stub
10
+ end
11
+
12
+ it 'returns a properly structured MCP text JSON response' do
13
+ response = subject
14
+ expect_mcp_text_json(response)
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp do
6
+ # Mode detection tests moved to mode_detector_spec.rb
7
+ # These tests verify the integration with ModeDetector
8
+ describe 'mode detection integration' do
9
+ it 'uses ModeDetector for CLI mode detection' do
10
+ expect(SimpleCovMcp::ModeDetector).to receive(:cli_mode?).with(['--force-cli'])
11
+ .and_return(true)
12
+ expect(SimpleCovMcp::CoverageCLI).to receive_message_chain(:new, :run)
13
+ SimpleCovMcp.run(['--force-cli'])
14
+ end
15
+ end
16
+ end