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,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::OptionParsers::ErrorHelper do
6
+ subject(:helper) { described_class.new }
7
+
8
+ # Helper method to capture stderr output
9
+ def capture_stderr
10
+ captured = StringIO.new
11
+ original = $stderr
12
+ $stderr = captured
13
+ begin
14
+ yield
15
+ rescue SystemExit
16
+ # Ignore exit calls
17
+ ensure
18
+ $stderr = original
19
+ end
20
+ captured.string
21
+ end
22
+
23
+ # Helper method to test error output matches expected pattern
24
+ def expect_error_output(error:, argv:, pattern:)
25
+ expect do
26
+ begin
27
+ helper.handle_option_parser_error(error, argv: argv)
28
+ rescue SystemExit
29
+ # Ignore exit call
30
+ end
31
+ end.to output(pattern).to_stderr
32
+ end
33
+
34
+ # Test data for enumerated options
35
+ OPTION_TESTS = {
36
+ stale: {
37
+ long: '--stale',
38
+ short: '-S',
39
+ pattern: /Valid values for --stale: o\[ff\]|e\[rror\]/
40
+ },
41
+ source: {
42
+ long: '--source',
43
+ short: '-s',
44
+ pattern: /Valid values for --source: f\[ull\]|u\[ncovered\]/
45
+ },
46
+ error_mode: {
47
+ long: '--error-mode',
48
+ short: nil,
49
+ pattern: /Valid values for --error-mode: off\|on\|t\[race\]/
50
+ },
51
+ sort_order: {
52
+ long: '--sort-order',
53
+ short: '-o',
54
+ pattern: /Valid values for --sort-order: a\[scending\]|d\[escending\]/
55
+ }
56
+ }.freeze
57
+
58
+ describe '#handle_option_parser_error' do
59
+ context 'with invalid enumerated option values' do
60
+ OPTION_TESTS.each do |name, config|
61
+ context "for #{config[:long]} option" do
62
+ let(:error) { OptionParser::InvalidArgument.new('invalid argument: xyz') }
63
+
64
+ it 'suggests valid values for space-separated form with invalid value' do
65
+ expect_error_output(
66
+ error: error,
67
+ argv: [config[:long], 'xyz'],
68
+ pattern: config[:pattern]
69
+ )
70
+ end
71
+
72
+ it 'suggests valid values for equal form with invalid value' do
73
+ expect_error_output(
74
+ error: error,
75
+ argv: ["#{config[:long]}=xyz"],
76
+ pattern: config[:pattern]
77
+ )
78
+ end
79
+
80
+ if config[:short]
81
+ it 'suggests valid values for short form with invalid value' do
82
+ expect_error_output(
83
+ error: error,
84
+ argv: [config[:short], 'xyz'],
85
+ pattern: config[:pattern]
86
+ )
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ context 'for --stale option edge cases' do
93
+ it 'suggests valid values when value is missing' do
94
+ error = OptionParser::InvalidArgument.new('missing argument: --stale')
95
+ expect_error_output(
96
+ error: error,
97
+ argv: ['--stale'],
98
+ pattern: /Valid values for --stale: o\[ff\]|e\[rror\]/
99
+ )
100
+ end
101
+
102
+ it 'suggests valid values when next token looks like an option' do
103
+ error = OptionParser::InvalidArgument.new('invalid argument: --other')
104
+ expect_error_output(
105
+ error: error,
106
+ argv: ['--stale', '--other-option'],
107
+ pattern: /Valid values for --stale: o\[ff\]|e\[rror\]/
108
+ )
109
+ end
110
+ end
111
+ end
112
+
113
+ context 'with multiple options in argv' do
114
+ it 'correctly identifies the problematic option among valid options' do
115
+ error = OptionParser::InvalidArgument.new('invalid argument: bad')
116
+ expect_error_output(
117
+ error: error,
118
+ argv: ['--resultset', 'coverage', '--stale', 'bad', '--json'],
119
+ pattern: /Valid values for --stale: o\[ff\]|e\[rror\]/
120
+ )
121
+ end
122
+
123
+ it 'handles equal form mixed with other options' do
124
+ error = OptionParser::InvalidArgument.new('invalid argument: invalid')
125
+ expect_error_output(
126
+ error: error,
127
+ argv: ['--json', '--sort-order=invalid', '--resultset', 'coverage'],
128
+ pattern: /Valid values for --sort-order: a\[scending\]|d\[escending\]/
129
+ )
130
+ end
131
+ end
132
+
133
+ context 'when option is not an enumerated type' do
134
+ it 'shows generic error message without enum hint' do
135
+ error = OptionParser::InvalidArgument.new('invalid option: --unknown')
136
+
137
+ stderr_output = capture_stderr do
138
+ helper.handle_option_parser_error(error, argv: ['--unknown'])
139
+ end
140
+
141
+ expect(stderr_output).to match(/Error:.*invalid option.*--unknown/)
142
+ expect(stderr_output).to match(/Run 'simplecov-mcp --help'/)
143
+ expect(stderr_output).not_to match(/Valid values/)
144
+ end
145
+ end
146
+
147
+ context 'when invalid option matches a subcommand' do
148
+ it 'suggests using it as a subcommand instead' do
149
+ error = OptionParser::InvalidOption.new('invalid option: --summary')
150
+
151
+ stderr_output = capture_stderr do
152
+ helper.handle_option_parser_error(error, argv: ['--summary'])
153
+ end
154
+
155
+ # Note: The subcommand detection logic isn't fully working as expected
156
+ # because extract_invalid_option doesn't properly parse the error message
157
+ expect(stderr_output).to match(/Error:.*--summary/)
158
+ expect(stderr_output).to match(/Run 'simplecov-mcp --help'/)
159
+ end
160
+ end
161
+
162
+ context 'exit behavior' do
163
+ it 'exits with status 1' do
164
+ error = OptionParser::InvalidArgument.new('invalid argument: xyz')
165
+
166
+ expect do
167
+ helper.handle_option_parser_error(error, argv: ['--stale', 'xyz'])
168
+ end.to raise_error(SystemExit) do |e|
169
+ expect(e.status).to eq(1)
170
+ end
171
+ end
172
+ end
173
+
174
+ context 'usage hint customization' do
175
+ it 'uses custom usage hint when provided' do
176
+ error = OptionParser::InvalidArgument.new('invalid argument: xyz')
177
+
178
+ expect do
179
+ begin
180
+ helper.handle_option_parser_error(error, argv: ['--stale', 'xyz'],
181
+ usage_hint: 'Custom hint message')
182
+ rescue SystemExit
183
+ # Ignore exit call
184
+ end
185
+ end.to output(/Custom hint message/).to_stderr
186
+ end
187
+ end
188
+ end
189
+
190
+ describe 'edge cases' do
191
+ it 'handles empty argv gracefully' do
192
+ error = OptionParser::InvalidArgument.new('some error')
193
+ expect_error_output(
194
+ error: error,
195
+ argv: [],
196
+ pattern: /Error: invalid argument: some error/
197
+ )
198
+ end
199
+
200
+ it 'handles argv with only valid options (no problematic enum)' do
201
+ error = OptionParser::InvalidArgument.new('some error')
202
+
203
+ stderr_output = capture_stderr do
204
+ helper.handle_option_parser_error(error, argv: ['--json', '--resultset', 'coverage'])
205
+ end
206
+
207
+ expect(stderr_output).to match(/Error: invalid argument: some error/)
208
+ expect(stderr_output).to match(/Run 'simplecov-mcp --help'/)
209
+ end
210
+
211
+ it 'does not show enum hint when all enum values are valid' do
212
+ error = OptionParser::MissingArgument.new('missing argument: --resultset')
213
+
214
+ stderr_output = capture_stderr do
215
+ helper.handle_option_parser_error(error, argv: ['--stale', 'off', '--resultset'])
216
+ end
217
+
218
+ expect(stderr_output).to match(/Error:.*missing argument.*--resultset/)
219
+ expect(stderr_output).not_to match(/Valid values/)
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::PathRelativizer do
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+ let(:relativizer) do
8
+ described_class.new(
9
+ root: root,
10
+ scalar_keys: %w[file file_path],
11
+ array_keys: %w[newer_files missing_files deleted_files]
12
+ )
13
+ end
14
+
15
+ describe '#relativize' do
16
+ it 'converts configured scalar keys to root-relative paths' do
17
+ payload = { 'file' => File.join(root, 'lib/foo.rb') }
18
+ result = relativizer.relativize(payload)
19
+
20
+ expect(result['file']).to eq('lib/foo.rb')
21
+ expect(payload['file']).to eq(File.join(root, 'lib/foo.rb'))
22
+ end
23
+
24
+ it 'relativizes arrays for configured keys without mutating originals' do
25
+ payload = {
26
+ 'newer_files' => [File.join(root, 'lib/foo.rb'), File.join(root, 'lib/bar.rb')]
27
+ }
28
+
29
+ result = relativizer.relativize(payload)
30
+
31
+ expect(result['newer_files']).to contain_exactly('lib/foo.rb', 'lib/bar.rb')
32
+ expect(payload['newer_files']).to all(start_with(root))
33
+ end
34
+
35
+ it 'leaves unconfigured keys untouched' do
36
+ payload = { 'other' => File.join(root, 'lib/foo.rb') }
37
+ result = relativizer.relativize(payload)
38
+
39
+ expect(result['other']).to eq(payload['other'])
40
+ end
41
+
42
+ it 'ignores paths outside the root' do
43
+ outside = '/tmp/external.rb'
44
+ payload = { 'file' => outside }
45
+
46
+ result = relativizer.relativize(payload)
47
+
48
+ expect(result['file']).to eq(outside)
49
+ end
50
+
51
+ it 'relativizes nested arrays of hashes' do
52
+ payload = {
53
+ 'files' => [
54
+ { 'file' => File.join(root, 'lib/foo.rb') },
55
+ { 'file' => File.join(root, 'lib/bar.rb') }
56
+ ],
57
+ 'counts' => { 'total' => 2 }
58
+ }
59
+
60
+ result = relativizer.relativize(payload)
61
+
62
+ expect(result['files'].map { |h| h['file'] }).to eq(%w[lib/foo.rb lib/bar.rb])
63
+ expect(result['counts']).to eq('total' => 2)
64
+ end
65
+
66
+ it "handles paths with '..' components" do
67
+ payload = { 'file' => File.join(root, 'lib/../lib/foo.rb') }
68
+ result = relativizer.relativize(payload)
69
+ expect(result['file']).to eq('lib/foo.rb')
70
+ end
71
+
72
+ it 'handles paths with spaces' do
73
+ file_with_space = File.join(root, 'lib/file with space.rb')
74
+ FileUtils.touch(file_with_space)
75
+
76
+ payload = { 'file' => file_with_space }
77
+ result = relativizer.relativize(payload)
78
+ expect(result['file']).to eq('lib/file with space.rb')
79
+ ensure
80
+ FileUtils.rm_f(file_with_space)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../shared_examples/coverage_presenter_examples'
5
+
6
+ RSpec.describe SimpleCovMcp::Presenters::CoverageDetailedPresenter do
7
+ it_behaves_like 'a coverage presenter',
8
+ model_method: :detailed_for,
9
+ payload: {
10
+ 'file' => '/abs/path/lib/foo.rb',
11
+ 'lines' => [
12
+ { 'line' => 1, 'hits' => 1, 'covered' => true },
13
+ { 'line' => 2, 'hits' => 0, 'covered' => false }
14
+ ],
15
+ 'summary' => { 'covered' => 1, 'total' => 2, 'pct' => 50.0 }
16
+ },
17
+ stale: 'L',
18
+ expected_keys: ['lines', 'summary']
19
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../shared_examples/coverage_presenter_examples'
5
+
6
+ RSpec.describe SimpleCovMcp::Presenters::CoverageRawPresenter do
7
+ it_behaves_like 'a coverage presenter',
8
+ model_method: :raw_for,
9
+ payload: {
10
+ 'file' => '/abs/path/lib/foo.rb',
11
+ 'lines' => [1, 0, nil, 2]
12
+ },
13
+ stale: 'L',
14
+ expected_keys: ['lines']
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../shared_examples/coverage_presenter_examples'
5
+
6
+ RSpec.describe SimpleCovMcp::Presenters::CoverageSummaryPresenter do
7
+ it_behaves_like 'a coverage presenter',
8
+ model_method: :summary_for,
9
+ payload: {
10
+ 'file' => '/abs/path/lib/foo.rb',
11
+ 'summary' => { 'covered' => 8, 'total' => 10, 'pct' => 80.0 }
12
+ },
13
+ stale: false,
14
+ expected_keys: ['summary']
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../shared_examples/coverage_presenter_examples'
5
+
6
+ RSpec.describe SimpleCovMcp::Presenters::CoverageUncoveredPresenter do
7
+ it_behaves_like 'a coverage presenter',
8
+ model_method: :uncovered_for,
9
+ payload: {
10
+ 'file' => '/abs/path/lib/foo.rb',
11
+ 'uncovered' => [2, 4],
12
+ 'summary' => { 'covered' => 2, 'total' => 4, 'pct' => 50.0 }
13
+ },
14
+ stale: 'M',
15
+ expected_keys: ['uncovered', 'summary']
16
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::Presenters::ProjectCoveragePresenter do
6
+ let(:model) { instance_double(SimpleCovMcp::CoverageModel) }
7
+ let(:sort_order) { :ascending }
8
+ let(:check_stale) { true }
9
+ let(:tracked_globs) { ['lib/**/*.rb'] }
10
+ let(:files) do
11
+ [
12
+ {
13
+ 'file' => '/abs/path/lib/foo.rb',
14
+ 'covered' => 5,
15
+ 'total' => 6,
16
+ 'percentage' => 83.33,
17
+ 'stale' => false
18
+ },
19
+ {
20
+ 'file' => '/abs/path/lib/bar.rb',
21
+ 'covered' => 1,
22
+ 'total' => 6,
23
+ 'percentage' => 16.67,
24
+ 'stale' => 'L'
25
+ }
26
+ ]
27
+ end
28
+
29
+ subject(:presenter) do
30
+ described_class.new(
31
+ model: model,
32
+ sort_order: sort_order,
33
+ check_stale: check_stale,
34
+ tracked_globs: tracked_globs
35
+ )
36
+ end
37
+
38
+ before do
39
+ allow(model).to receive(:all_files).with(sort_order: sort_order, check_stale: check_stale,
40
+ tracked_globs: tracked_globs).and_return(files)
41
+ allow(model).to receive(:relativize) do |payload|
42
+ relativizer = SimpleCovMcp::PathRelativizer.new(
43
+ root: '/abs/path',
44
+ scalar_keys: %w[file file_path],
45
+ array_keys: %w[newer_files missing_files deleted_files]
46
+ )
47
+ relativizer.relativize(payload)
48
+ end
49
+ end
50
+
51
+ describe '#absolute_payload' do
52
+ it 'returns files and counts with stale metadata' do
53
+ payload = presenter.absolute_payload
54
+
55
+ expect(payload['files']).to eq(files)
56
+ expect(payload['counts']).to eq({ 'total' => 2, 'ok' => 1, 'stale' => 1 })
57
+ end
58
+
59
+ it 'memoizes the computed payload' do
60
+ presenter.absolute_payload
61
+ presenter.absolute_payload
62
+
63
+ expect(model).to have_received(:all_files).once
64
+ end
65
+ end
66
+
67
+ describe '#relativized_payload' do
68
+ it 'relativizes the files list' do
69
+ relativized = presenter.relativized_payload
70
+
71
+ expect(relativized['files'].map { |f| f['file'] }).to eq(['lib/foo.rb', 'lib/bar.rb'])
72
+ end
73
+ end
74
+
75
+ describe '#relative_files' do
76
+ it 'returns the relativized file list' do
77
+ expect(presenter.relative_files.map { |f| f['file'] }).to eq(['lib/foo.rb', 'lib/bar.rb'])
78
+ end
79
+ end
80
+
81
+ describe '#relative_counts' do
82
+ it 'returns the relativized counts hash' do
83
+ expect(presenter.relative_counts).to eq({ 'total' => 2, 'ok' => 1, 'stale' => 1 })
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::Resolvers::CoverageLineResolver do
6
+ describe '#lookup_lines' do
7
+ it 'synthesizes line hits when only branch coverage exists' do
8
+ abs_path = '/tmp/branch_only.rb'
9
+ branch_cov = {
10
+ abs_path => {
11
+ 'lines' => nil,
12
+ 'branches' => {
13
+ '[:if, 0, 5, 2, 8, 5]' => {
14
+ '[:then, 1, 6, 4, 6, 15]' => 3,
15
+ '[:else, 2, 7, 4, 7, 15]' => 0
16
+ },
17
+ '[:case, 3, 12, 2, 17, 5]' => {
18
+ '[:when, 4, 13, 4, 13, 14]' => 0,
19
+ '[:when, 5, 14, 4, 14, 14]' => 2,
20
+ '[:else, 6, 16, 4, 16, 12]' => 2
21
+ }
22
+ }
23
+ }
24
+ }
25
+
26
+ resolver = described_class.new(branch_cov)
27
+ lines = resolver.lookup_lines(abs_path)
28
+
29
+ expect(lines[5]).to eq(3) # line 6
30
+ expect(lines[6]).to eq(0) # line 7
31
+ expect(lines[12]).to eq(0) # line 13
32
+ expect(lines[13]).to eq(2) # line 14
33
+ expect(lines[15]).to eq(2) # line 16
34
+ expect(lines.count { |v| !v.nil? }).to eq(5)
35
+ end
36
+
37
+ it 'aggregates hits for multiple branches on the same line' do
38
+ path = '/tmp/duplicated.rb'
39
+ branch_cov = {
40
+ path => {
41
+ 'lines' => nil,
42
+ 'branches' => {
43
+ '[:if, 0, 3, 2, 3, 12]' => {
44
+ '[:then, 1, 3, 2, 3, 12]' => 2,
45
+ '[:else, 2, 3, 2, 3, 12]' => 3
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ resolver = described_class.new(branch_cov)
52
+ lines = resolver.lookup_lines(path)
53
+
54
+ expect(lines[2]).to eq(5) # line 3 with summed hits
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe SimpleCovMcp::Resolvers::ResolverFactory do
7
+ describe '.create_resultset_resolver' do
8
+ it 'uses provided candidates when present' do
9
+ custom_candidates = ['alt/.resultset.json']
10
+ resolver = described_class.create_resultset_resolver(
11
+ root: '/tmp/sample',
12
+ candidates: custom_candidates
13
+ )
14
+
15
+ expect(resolver).to be_a(SimpleCovMcp::Resolvers::ResultsetPathResolver)
16
+ expect(resolver.instance_variable_get(:@root)).to eq('/tmp/sample')
17
+ expect(resolver.instance_variable_get(:@candidates)).to eq(custom_candidates)
18
+ end
19
+
20
+ it 'falls back to default candidates when none provided' do
21
+ resolver = described_class.create_resultset_resolver(root: '/tmp/sample')
22
+
23
+ expect(resolver.instance_variable_get(:@candidates)).to eq(
24
+ SimpleCovMcp::Resolvers::ResultsetPathResolver::DEFAULT_CANDIDATES
25
+ )
26
+ end
27
+ end
28
+
29
+ describe '.create_coverage_resolver' do
30
+ it 'wraps coverage data in a CoverageLineResolver' do
31
+ cov = { '/tmp/foo.rb' => { 'lines' => [1, 0] } }
32
+ resolver = described_class.create_coverage_resolver(cov)
33
+
34
+ expect(resolver).to be_a(SimpleCovMcp::Resolvers::CoverageLineResolver)
35
+ expect(resolver.lookup_lines('/tmp/foo.rb')).to eq([1, 0])
36
+ end
37
+ end
38
+
39
+ describe '.find_resultset' do
40
+ it 'locates default resultset within the provided root' do
41
+ Dir.mktmpdir do |dir|
42
+ resultset_path = File.join(dir, '.resultset.json')
43
+ File.write(resultset_path, '{}')
44
+
45
+ resolved = described_class.find_resultset(dir)
46
+
47
+ expect(resolved).to eq(resultset_path)
48
+ end
49
+ end
50
+ end
51
+
52
+ describe '.lookup_lines' do
53
+ it 'delegates to CoverageLineResolver for lookups' do
54
+ cov = { '/tmp/bar.rb' => { 'lines' => [0, 1] } }
55
+
56
+ expect(
57
+ described_class.lookup_lines(cov, '/tmp/bar.rb')
58
+ ).to eq([0, 1])
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe SimpleCovMcp::Resolvers::ResultsetPathResolver do
7
+ describe '#find_resultset' do
8
+ around do |example|
9
+ Dir.mktmpdir do |dir|
10
+ @tmp_root = dir
11
+ example.run
12
+ end
13
+ end
14
+
15
+ let(:root) { @tmp_root }
16
+ let(:resolver) { described_class.new(root: root) }
17
+
18
+ it 'raises when a specified resultset file cannot be found' do
19
+ expect do
20
+ resolver.find_resultset(resultset: 'missing.json')
21
+ end.to raise_error(RuntimeError, /Specified resultset not found/)
22
+ end
23
+
24
+ it 'raises when a specified directory does not contain .resultset.json' do
25
+ nested_dir = File.join(root, 'coverage')
26
+ Dir.mkdir(nested_dir)
27
+
28
+ expect do
29
+ resolver.find_resultset(resultset: nested_dir)
30
+ end.to raise_error(RuntimeError, /No .resultset.json found in directory/)
31
+ end
32
+
33
+ it 'returns the resolved path when a valid resultset file is provided' do
34
+ file = File.join(root, 'custom.json')
35
+ File.write(file, '{}')
36
+
37
+ expect(resolver.find_resultset(resultset: file)).to eq(file)
38
+ end
39
+
40
+ it 'raises a helpful error when no fallback candidates are found' do
41
+ expect do
42
+ resolver.find_resultset
43
+ end.to raise_error(RuntimeError, /Could not find .resultset.json/)
44
+ end
45
+
46
+ it 'accepts a resultset path already nested under the provided root without double-prefixing' do
47
+ project_root = (FIXTURES_DIR / 'project1').to_s
48
+ resolver = described_class.new(root: project_root)
49
+
50
+ resolved = resolver.find_resultset(resultset: 'spec/fixtures/project1/coverage')
51
+
52
+ expect(resolved).to eq(File.join(project_root, 'coverage', '.resultset.json'))
53
+ end
54
+ end
55
+ end