simplecov-mcp 1.0.1 → 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 +12 -19
  48. data/lib/simplecov_mcp/formatters/source_formatter.rb +23 -19
  49. data/lib/simplecov_mcp/formatters.rb +51 -0
  50. data/lib/simplecov_mcp/mcp_server.rb +9 -7
  51. data/lib/simplecov_mcp/mode_detector.rb +6 -5
  52. data/lib/simplecov_mcp/model.rb +122 -88
  53. data/lib/simplecov_mcp/option_normalizers.rb +39 -18
  54. data/lib/simplecov_mcp/option_parser_builder.rb +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 +103 -161
  118. data/spec/logging_fallback_spec.rb +3 -3
  119. data/spec/mcp_server_integration_spec.rb +1 -1
  120. data/spec/mcp_server_spec.rb +70 -53
  121. data/spec/mode_detector_spec.rb +46 -41
  122. data/spec/model_error_handling_spec.rb +139 -78
  123. data/spec/model_staleness_spec.rb +13 -13
  124. data/spec/option_normalizers_spec.rb +111 -112
  125. data/spec/option_parsers/env_options_parser_spec.rb +25 -37
  126. data/spec/option_parsers/error_helper_spec.rb +56 -56
  127. data/spec/path_relativizer_spec.rb +15 -0
  128. data/spec/presenters/coverage_detailed_presenter_spec.rb +1 -1
  129. data/spec/presenters/coverage_summary_presenter_spec.rb +1 -1
  130. data/spec/presenters/coverage_uncovered_presenter_spec.rb +1 -1
  131. data/spec/presenters/project_coverage_presenter_spec.rb +9 -8
  132. data/spec/presenters/project_totals_presenter_spec.rb +144 -0
  133. data/spec/resolvers/coverage_line_resolver_spec.rb +261 -36
  134. data/spec/resolvers/resultset_path_resolver_spec.rb +13 -8
  135. data/spec/shared_examples/file_based_mcp_tools.rb +23 -18
  136. data/spec/shared_examples/formatted_command_examples.rb +64 -0
  137. data/spec/simple_cov_mcp_module_spec.rb +24 -3
  138. data/spec/simplecov_mcp/formatters/source_formatter_spec.rb +267 -0
  139. data/spec/simplecov_mcp/formatters_spec.rb +76 -0
  140. data/spec/simplecov_mcp/presenters/base_coverage_presenter_spec.rb +79 -0
  141. data/spec/simplecov_mcp_model_spec.rb +97 -47
  142. data/spec/simplecov_mcp_opts_spec.rb +42 -39
  143. data/spec/spec_helper.rb +27 -92
  144. data/spec/staleness_checker_spec.rb +10 -9
  145. data/spec/staleness_more_spec.rb +4 -4
  146. data/spec/support/cli_helpers.rb +22 -0
  147. data/spec/support/control_flow_helpers.rb +20 -0
  148. data/spec/support/fake_mcp.rb +40 -0
  149. data/spec/support/io_helpers.rb +29 -0
  150. data/spec/support/mcp_helpers.rb +35 -0
  151. data/spec/support/mcp_runner.rb +10 -8
  152. data/spec/support/mocking_helpers.rb +30 -0
  153. data/spec/table_format_spec.rb +70 -0
  154. data/spec/tools/validate_tool_spec.rb +132 -0
  155. data/spec/tools_error_handling_spec.rb +34 -48
  156. data/spec/util_spec.rb +5 -4
  157. data/spec/version_spec.rb +7 -7
  158. data/spec/version_tool_spec.rb +20 -22
  159. metadata +90 -23
  160. data/docs/BRANCH_ONLY_COVERAGE.md +0 -81
  161. data/docs/CLI_USAGE.md +0 -637
  162. data/docs/EXAMPLES.md +0 -430
  163. data/docs/INSTALLATION.md +0 -352
  164. data/spec/cli_success_predicate_spec.rb +0 -141
@@ -3,6 +3,15 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  RSpec.describe SimpleCovMcp::Presenters::ProjectCoveragePresenter do
6
+ subject(:presenter) do
7
+ described_class.new(
8
+ model: model,
9
+ sort_order: sort_order,
10
+ check_stale: check_stale,
11
+ tracked_globs: tracked_globs
12
+ )
13
+ end
14
+
6
15
  let(:model) { instance_double(SimpleCovMcp::CoverageModel) }
7
16
  let(:sort_order) { :ascending }
8
17
  let(:check_stale) { true }
@@ -26,14 +35,6 @@ RSpec.describe SimpleCovMcp::Presenters::ProjectCoveragePresenter do
26
35
  ]
27
36
  end
28
37
 
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
 
38
39
  before do
39
40
  allow(model).to receive(:all_files).with(sort_order: sort_order, check_stale: check_stale,
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::Presenters::ProjectTotalsPresenter do
6
+ subject(:presenter) do
7
+ described_class.new(
8
+ model: model,
9
+ check_stale: true,
10
+ tracked_globs: ['lib/**/*.rb']
11
+ )
12
+ end
13
+
14
+ let(:model) { instance_double(SimpleCovMcp::CoverageModel) }
15
+ let(:raw_totals) do
16
+ {
17
+ 'lines' => { 'total' => 100, 'covered' => 80, 'uncovered' => 20 },
18
+ 'percentage' => 80.0,
19
+ 'files' => { 'total' => 10, 'ok' => 9, 'stale' => 1 }
20
+ }
21
+ end
22
+
23
+ before do
24
+ allow(model).to receive(:project_totals)
25
+ .with(tracked_globs: ['lib/**/*.rb'], check_stale: true)
26
+ .and_return(raw_totals)
27
+ allow(model).to receive(:relativize) { |payload| payload }
28
+ end
29
+
30
+ describe '#initialize' do
31
+ it 'stores the model, check_stale, and tracked_globs options' do
32
+ expect(presenter.model).to eq(model)
33
+ expect(presenter.check_stale).to be(true)
34
+ expect(presenter.tracked_globs).to eq(['lib/**/*.rb'])
35
+ end
36
+ end
37
+
38
+ describe '#absolute_payload' do
39
+ it 'returns project totals from the model' do
40
+ result = presenter.absolute_payload
41
+
42
+ expect(result).to include('lines', 'percentage', 'files')
43
+ expect(result['lines']).to include('total' => 100, 'covered' => 80, 'uncovered' => 20)
44
+ expect(result['percentage']).to eq(80.0)
45
+ expect(result['files']).to include('total' => 10, 'ok' => 9, 'stale' => 1)
46
+ end
47
+
48
+ it 'caches the result on subsequent calls' do
49
+ presenter.absolute_payload
50
+ presenter.absolute_payload
51
+
52
+ expect(model).to have_received(:project_totals).once
53
+ end
54
+
55
+ it 'passes tracked_globs to the model' do
56
+ presenter.absolute_payload
57
+
58
+ expect(model).to have_received(:project_totals)
59
+ .with(tracked_globs: ['lib/**/*.rb'], check_stale: true)
60
+ end
61
+ end
62
+
63
+ describe '#relativized_payload' do
64
+ it 'returns the relativized payload from the model' do
65
+ result = presenter.relativized_payload
66
+
67
+ expect(result).to eq(raw_totals)
68
+ end
69
+
70
+ it 'calls relativize on the model' do
71
+ presenter.relativized_payload
72
+
73
+ expect(model).to have_received(:relativize).with(raw_totals)
74
+ end
75
+
76
+ it 'caches the result on subsequent calls' do
77
+ presenter.relativized_payload
78
+ presenter.relativized_payload
79
+
80
+ expect(model).to have_received(:relativize).once
81
+ end
82
+ end
83
+
84
+ context 'with check_stale: false' do
85
+ subject(:presenter) do
86
+ described_class.new(
87
+ model: model,
88
+ check_stale: false,
89
+ tracked_globs: nil
90
+ )
91
+ end
92
+
93
+ before do
94
+ allow(model).to receive(:project_totals)
95
+ .with(tracked_globs: nil, check_stale: false)
96
+ .and_return(raw_totals)
97
+ end
98
+
99
+ it 'passes check_stale: false to the model' do
100
+ presenter.absolute_payload
101
+
102
+ expect(model).to have_received(:project_totals)
103
+ .with(tracked_globs: nil, check_stale: false)
104
+ end
105
+ end
106
+
107
+ context 'with empty tracked_globs' do
108
+ subject(:presenter) do
109
+ described_class.new(
110
+ model: model,
111
+ check_stale: true,
112
+ tracked_globs: []
113
+ )
114
+ end
115
+
116
+ before do
117
+ allow(model).to receive(:project_totals)
118
+ .with(tracked_globs: [], check_stale: true)
119
+ .and_return(raw_totals)
120
+ end
121
+
122
+ it 'passes empty tracked_globs to the model' do
123
+ presenter.absolute_payload
124
+
125
+ expect(model).to have_received(:project_totals)
126
+ .with(tracked_globs: [], check_stale: true)
127
+ end
128
+ end
129
+
130
+ context 'with relativization that transforms data' do
131
+ before do
132
+ allow(model).to receive(:relativize) do |payload|
133
+ # Simulate relativization that might transform file paths in nested data
134
+ payload.merge('transformed' => true)
135
+ end
136
+ end
137
+
138
+ it 'applies the transformation from relativize' do
139
+ result = presenter.relativized_payload
140
+
141
+ expect(result['transformed']).to be(true)
142
+ end
143
+ end
144
+ end
@@ -4,54 +4,279 @@ require 'spec_helper'
4
4
 
5
5
  RSpec.describe SimpleCovMcp::Resolvers::CoverageLineResolver do
6
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
7
+ context 'with direct path matching' do
8
+ it 'returns lines array for exact path match' do
9
+ abs_path = '/project/lib/foo.rb'
10
+ cov_data = {
11
+ abs_path => { 'lines' => [1, 0, nil, 2] }
12
+ }
13
+
14
+ resolver = described_class.new(cov_data)
15
+ lines = resolver.lookup_lines(abs_path)
16
+
17
+ expect(lines).to eq([1, 0, nil, 2])
18
+ end
19
+
20
+ it 'returns lines array when entry has lines directly' do
21
+ path = '/tmp/test.rb'
22
+ cov_data = {
23
+ path => { 'lines' => [1, 1, 1] }
24
+ }
25
+
26
+ resolver = described_class.new(cov_data)
27
+ expect(resolver.lookup_lines(path)).to eq([1, 1, 1])
28
+ end
29
+ end
30
+
31
+ context 'with CWD stripping fallback' do
32
+ it 'finds relative path when absolute path includes CWD' do
33
+ cwd = Dir.pwd
34
+ relative_path = 'lib/bar.rb'
35
+ abs_path = File.join(cwd, relative_path)
36
+ cov_data = {
37
+ relative_path => { 'lines' => [1, 0, 1] }
38
+ }
39
+
40
+ resolver = described_class.new(cov_data)
41
+ lines = resolver.lookup_lines(abs_path)
42
+
43
+ expect(lines).to eq([1, 0, 1])
44
+ end
45
+
46
+ it 'does not match when absolute path does not start with CWD' do
47
+ cov_data = {
48
+ 'lib/baz.rb' => { 'lines' => [1, 1] }
49
+ }
50
+
51
+ resolver = described_class.new(cov_data)
52
+
53
+ expect do
54
+ resolver.lookup_lines('/other/directory/lib/baz.rb')
55
+ end.to raise_error(SimpleCovMcp::FileError, /No coverage entry found/)
56
+ end
57
+ end
58
+
59
+ context 'when handling errors' do
60
+ it 'raises FileError when file is not found in coverage data' do
61
+ cov_data = {
62
+ '/project/lib/foo.rb' => { 'lines' => [1, 0] }
63
+ }
64
+
65
+ resolver = described_class.new(cov_data)
66
+
67
+ expect do
68
+ resolver.lookup_lines('/project/lib/missing.rb')
69
+ end.to raise_error(SimpleCovMcp::FileError, /No coverage entry found/)
70
+ end
71
+
72
+ it 'raises FileError when coverage data is empty' do
73
+ resolver = described_class.new({})
74
+
75
+ expect do
76
+ resolver.lookup_lines('/any/path.rb')
77
+ end.to raise_error(SimpleCovMcp::FileError, /No coverage entry found/)
78
+ end
79
+
80
+ it 'raises FileError when entry exists but has no lines or branches' do
81
+ cov_data = {
82
+ '/project/lib/foo.rb' => { 'other_key' => 'value' }
83
+ }
84
+
85
+ resolver = described_class.new(cov_data)
86
+
87
+ expect do
88
+ resolver.lookup_lines('/project/lib/foo.rb')
89
+ end.to raise_error(SimpleCovMcp::FileError, /No coverage entry found/)
90
+ end
91
+ end
92
+
93
+ context 'with branch-only coverage synthesis' do
94
+ it 'synthesizes line hits when only branch coverage exists' do
95
+ abs_path = '/tmp/branch_only.rb'
96
+ branch_cov = {
97
+ abs_path => {
98
+ 'lines' => nil,
99
+ 'branches' => {
100
+ '[:if, 0, 5, 2, 8, 5]' => {
101
+ '[:then, 1, 6, 4, 6, 15]' => 3,
102
+ '[:else, 2, 7, 4, 7, 15]' => 0
103
+ },
104
+ '[:case, 3, 12, 2, 17, 5]' => {
105
+ '[:when, 4, 13, 4, 13, 14]' => 0,
106
+ '[:when, 5, 14, 4, 14, 14]' => 2,
107
+ '[:else, 6, 16, 4, 16, 12]' => 2
108
+ }
21
109
  }
22
110
  }
23
111
  }
24
- }
25
112
 
26
- resolver = described_class.new(branch_cov)
27
- lines = resolver.lookup_lines(abs_path)
113
+ resolver = described_class.new(branch_cov)
114
+ lines = resolver.lookup_lines(abs_path)
115
+
116
+ expect(lines[5]).to eq(3) # line 6
117
+ expect(lines[6]).to eq(0) # line 7
118
+ expect(lines[12]).to eq(0) # line 13
119
+ expect(lines[13]).to eq(2) # line 14
120
+ expect(lines[15]).to eq(2) # line 16
121
+ expect(lines.count { |v| !v.nil? }).to eq(5)
122
+ end
123
+
124
+ it 'aggregates hits for multiple branches on the same line' do
125
+ path = '/tmp/duplicated.rb'
126
+ branch_cov = {
127
+ path => {
128
+ 'lines' => nil,
129
+ 'branches' => {
130
+ '[:if, 0, 3, 2, 3, 12]' => {
131
+ '[:then, 1, 3, 2, 3, 12]' => 2,
132
+ '[:else, 2, 3, 2, 3, 12]' => 3
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ resolver = described_class.new(branch_cov)
139
+ lines = resolver.lookup_lines(path)
140
+
141
+ expect(lines[2]).to eq(5) # line 3 with summed hits
142
+ end
143
+
144
+ it 'handles array-style branch metadata' do
145
+ path = '/tmp/array_style.rb'
146
+ cov_data = {
147
+ path => {
148
+ 'lines' => nil,
149
+ 'branches' => {
150
+ [:if, 0, 5, 2, 8, 5] => {
151
+ [:then, 1, 6, 4, 6, 15] => 2
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ resolver = described_class.new(cov_data)
158
+ lines = resolver.lookup_lines(path)
159
+
160
+ expect(lines[5]).to eq(2) # line 6
161
+ end
162
+
163
+ it 'returns nil for entries with empty branches' do
164
+ path = '/tmp/empty_branches.rb'
165
+ cov_data = {
166
+ path => {
167
+ 'lines' => nil,
168
+ 'branches' => {}
169
+ }
170
+ }
171
+
172
+ resolver = described_class.new(cov_data)
173
+
174
+ expect do
175
+ resolver.lookup_lines(path)
176
+ end.to raise_error(SimpleCovMcp::FileError)
177
+ end
178
+
179
+ it 'skips malformed branch entries' do
180
+ path = '/tmp/malformed.rb'
181
+ cov_data = {
182
+ path => {
183
+ 'lines' => nil,
184
+ 'branches' => {
185
+ '[:if, 0, 5, 2, 8, 5]' => {
186
+ '[:then, 1, 6, 4, 6, 15]' => 2
187
+ },
188
+ 'malformed_key' => 'not_a_hash',
189
+ '[:if, 1, 10]' => { # missing elements in tuple
190
+ '[:then]' => 1 # also malformed
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ resolver = described_class.new(cov_data)
197
+ lines = resolver.lookup_lines(path)
198
+
199
+ # Should still get line 6 from the valid branch
200
+ expect(lines[5]).to eq(2)
201
+ end
202
+ end
203
+
204
+ context 'with extract_line_number edge cases' do
205
+ let(:resolver) { described_class.new({}) }
206
+
207
+ it 'extracts line number from array metadata' do
208
+ result = resolver.send(:extract_line_number, [:if, 0, 10, 2, 15, 5])
209
+ expect(result).to eq(10)
210
+ end
211
+
212
+ it 'extracts line number from stringified array metadata' do
213
+ result = resolver.send(:extract_line_number, '[:if, 0, 15, 2, 20, 5]')
214
+ expect(result).to eq(15)
215
+ end
216
+
217
+ it 'returns nil for short array' do
218
+ result = resolver.send(:extract_line_number, [:if, 0])
219
+ expect(result).to be_nil
220
+ end
221
+
222
+ it 'returns nil for short string' do
223
+ result = resolver.send(:extract_line_number, '[:if, 0]')
224
+ expect(result).to be_nil
225
+ end
226
+
227
+ it 'returns nil for non-numeric line element in array' do
228
+ result = resolver.send(:extract_line_number, [:if, 0, 'not_a_number', 2])
229
+ expect(result).to be_nil
230
+ end
231
+
232
+ it 'returns nil for non-numeric line element in string' do
233
+ result = resolver.send(:extract_line_number, '[:if, 0, abc, 2]')
234
+ expect(result).to be_nil
235
+ end
236
+
237
+ it 'handles empty string' do
238
+ result = resolver.send(:extract_line_number, '')
239
+ expect(result).to be_nil
240
+ end
241
+
242
+ it 'handles nil input' do
243
+ result = resolver.send(:extract_line_number, nil)
244
+ expect(result).to be_nil
245
+ end
246
+
247
+ # The rescue block catches ArgumentError/TypeError from malformed metadata
248
+ # that can't be converted to line numbers.
249
+ [ArgumentError, TypeError].each do |error_class|
250
+ it "returns nil when string operations raise #{error_class}" do
251
+ weird_object = Object.new
252
+ allow(weird_object).to receive(:to_s).and_raise(error_class, 'test error')
28
253
 
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)
254
+ result = resolver.send(:extract_line_number, weird_object)
255
+ expect(result).to be_nil
256
+ end
257
+ end
35
258
  end
36
259
 
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
260
+ context 'with preference for lines over branches' do
261
+ it 'prefers lines array when both lines and branches exist' do
262
+ path = '/tmp/both.rb'
263
+ cov_data = {
264
+ path => {
265
+ 'lines' => [1, 2, 3],
266
+ 'branches' => {
267
+ '[:if, 0, 100, 2, 105, 5]' => {
268
+ '[:then, 1, 101, 4, 101, 15]' => 99
269
+ }
46
270
  }
47
271
  }
48
272
  }
49
- }
50
273
 
51
- resolver = described_class.new(branch_cov)
52
- lines = resolver.lookup_lines(path)
274
+ resolver = described_class.new(cov_data)
275
+ lines = resolver.lookup_lines(path)
53
276
 
54
- expect(lines[2]).to eq(5) # line 3 with summed hits
277
+ # Should return the lines array, not synthesized branch data
278
+ expect(lines).to eq([1, 2, 3])
279
+ end
55
280
  end
56
281
  end
57
282
  end
@@ -2,19 +2,17 @@
2
2
 
3
3
  require 'spec_helper'
4
4
  require 'tmpdir'
5
+ require 'fileutils'
5
6
 
6
7
  RSpec.describe SimpleCovMcp::Resolvers::ResultsetPathResolver do
7
8
  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 }
9
+ let(:root) { Dir.mktmpdir }
16
10
  let(:resolver) { described_class.new(root: root) }
17
11
 
12
+ after do
13
+ FileUtils.remove_entry(root) if root && Dir.exist?(root)
14
+ end
15
+
18
16
  it 'raises when a specified resultset file cannot be found' do
19
17
  expect do
20
18
  resolver.find_resultset(resultset: 'missing.json')
@@ -51,5 +49,12 @@ RSpec.describe SimpleCovMcp::Resolvers::ResultsetPathResolver do
51
49
 
52
50
  expect(resolved).to eq(File.join(project_root, 'coverage', '.resultset.json'))
53
51
  end
52
+
53
+ # In non-strict mode, resolve_candidate returns nil instead of raising
54
+ # when the path doesn't exist, allowing fallback resolution to continue.
55
+ it 'returns nil for non-existent path in non-strict mode' do
56
+ result = resolver.send(:resolve_candidate, '/nonexistent/path.json', strict: false)
57
+ expect(result).to be_nil
58
+ end
54
59
  end
55
60
  end
@@ -9,6 +9,8 @@ require 'spec_helper'
9
9
  # - Have predictable output filename
10
10
 
11
11
  RSpec.shared_examples 'a file-based MCP tool' do |config|
12
+ subject { tool_class.call(path: 'lib/foo.rb', server_context: server_context) }
13
+
12
14
  let(:server_context) { instance_double('ServerContext').as_null_object }
13
15
  let(:tool_class) { config[:tool_class] }
14
16
  let(:model_method) { config[:model_method] }
@@ -31,7 +33,6 @@ RSpec.shared_examples 'a file-based MCP tool' do |config|
31
33
  allow(model).to receive(:staleness_for).with('lib/foo.rb').and_return(false)
32
34
  end
33
35
 
34
- subject { tool_class.call(path: 'lib/foo.rb', server_context: server_context) }
35
36
 
36
37
  it_behaves_like 'an MCP tool that returns text JSON'
37
38
 
@@ -44,7 +45,7 @@ RSpec.shared_examples 'a file-based MCP tool' do |config|
44
45
  end
45
46
 
46
47
  expect(data).to have_key('stale')
47
- expect(data['stale']).to eq(false)
48
+ expect(data['stale']).to be(false)
48
49
 
49
50
  # Run tool-specific validations if provided
50
51
  if additional_validations
@@ -56,7 +57,7 @@ RSpec.shared_examples 'a file-based MCP tool' do |config|
56
57
  tool_specific_examples = config[:tool_specific_examples] || {}
57
58
  tool_specific_examples.each do |example_name, example_block|
58
59
  it example_name do
59
- instance_exec(config, &example_block)
60
+ expect { instance_exec(config, &example_block) }.not_to raise_error
60
61
  end
61
62
  end
62
63
  end
@@ -71,17 +72,19 @@ FILE_BASED_TOOL_CONFIGS = {
71
72
  description: 'coverage summary data',
72
73
  mock_data: {
73
74
  'file' => '/abs/path/lib/foo.rb',
74
- 'summary' => { 'covered' => 10, 'total' => 12, 'pct' => 83.33 }
75
+ 'summary' => { 'covered' => 10, 'total' => 12, 'percentage' => 83.33 }
75
76
  },
76
- additional_validations: ->(data, item) {
77
- expect(data['summary']).to include('covered', 'total', 'pct')
77
+ additional_validations: ->(data, _item) {
78
+ expect(data['summary']).to include('covered', 'total', 'percentage')
78
79
  },
79
80
  tool_specific_examples: {
80
81
  'includes percentage in summary data' => ->(config) {
81
82
  model = instance_double(SimpleCovMcp::CoverageModel)
82
83
  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)
84
+ allow(model).to receive_messages(
85
+ summary_for: config[:mock_data],
86
+ staleness_for: false
87
+ )
85
88
  relativizer = SimpleCovMcp::PathRelativizer.new(
86
89
  root: '/abs/path',
87
90
  scalar_keys: %w[file file_path],
@@ -92,9 +95,9 @@ FILE_BASED_TOOL_CONFIGS = {
92
95
 
93
96
  response = config[:tool_class].call(path: 'lib/foo.rb',
94
97
  server_context: instance_double('ServerContext').as_null_object)
95
- data, _ = expect_mcp_text_json(response)
98
+ data, = expect_mcp_text_json(response)
96
99
 
97
- expect(data['summary']['pct']).to be_a(Float)
100
+ expect(data['summary']['percentage']).to be_a(Float)
98
101
  }
99
102
  }
100
103
  },
@@ -123,17 +126,19 @@ FILE_BASED_TOOL_CONFIGS = {
123
126
  mock_data: {
124
127
  'file' => '/abs/path/lib/foo.rb',
125
128
  'uncovered' => [5, 9, 12],
126
- 'summary' => { 'covered' => 10, 'total' => 12, 'pct' => 83.33 }
129
+ 'summary' => { 'covered' => 10, 'total' => 12, 'percentage' => 83.33 }
127
130
  },
128
- additional_validations: ->(data, item) {
131
+ additional_validations: ->(data, _item) {
129
132
  expect(data['uncovered']).to eq([5, 9, 12])
130
133
  },
131
134
  tool_specific_examples: {
132
135
  'includes both uncovered lines and summary' => ->(config) {
133
136
  model = instance_double(SimpleCovMcp::CoverageModel)
134
137
  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)
138
+ allow(model).to receive_messages(
139
+ uncovered_for: config[:mock_data],
140
+ staleness_for: false
141
+ )
137
142
  relativizer = SimpleCovMcp::PathRelativizer.new(
138
143
  root: '/abs/path',
139
144
  scalar_keys: %w[file file_path],
@@ -144,10 +149,10 @@ FILE_BASED_TOOL_CONFIGS = {
144
149
 
145
150
  response = config[:tool_class].call(path: 'lib/foo.rb',
146
151
  server_context: instance_double('ServerContext').as_null_object)
147
- data, _ = expect_mcp_text_json(response)
152
+ data, = expect_mcp_text_json(response)
148
153
 
149
154
  expect(data['uncovered']).to be_an(Array)
150
- expect(data['summary']).to include('covered', 'total', 'pct')
155
+ expect(data['summary']).to include('covered', 'total', 'percentage')
151
156
  }
152
157
  }
153
158
  },
@@ -164,9 +169,9 @@ FILE_BASED_TOOL_CONFIGS = {
164
169
  { 'line' => 1, 'hits' => 1, 'covered' => true },
165
170
  { 'line' => 2, 'hits' => 0, 'covered' => false }
166
171
  ],
167
- 'summary' => { 'covered' => 1, 'total' => 2, 'pct' => 50.0 }
172
+ 'summary' => { 'covered' => 1, 'total' => 2, 'percentage' => 50.0 }
168
173
  },
169
- additional_validations: ->(data, item) {
174
+ additional_validations: ->(data, _item) {
170
175
  expect(data['lines']).to be_an(Array)
171
176
  expect(data['lines'].first).to include('line', 'hits', 'covered')
172
177
  }