simplecov-mcp 1.0.1 → 2.0.1
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.
- checksums.yaml +4 -4
- data/README.md +98 -50
- data/docs/{ARCHITECTURE.md → dev/ARCHITECTURE.md} +11 -10
- data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
- data/docs/{DEVELOPMENT.md → dev/DEVELOPMENT.md} +2 -1
- data/docs/dev/README.md +10 -0
- data/docs/dev/RELEASING.md +146 -0
- data/docs/{arch-decisions → dev/arch-decisions}/001-x-arch-decision.md +3 -1
- data/docs/{arch-decisions → dev/arch-decisions}/002-x-arch-decision.md +7 -5
- data/docs/{arch-decisions → dev/arch-decisions}/003-x-arch-decision.md +2 -0
- data/docs/{arch-decisions → dev/arch-decisions}/004-x-arch-decision.md +6 -2
- data/docs/{arch-decisions → dev/arch-decisions}/005-x-arch-decision.md +4 -2
- data/docs/{arch-decisions → dev/arch-decisions}/README.md +3 -3
- data/docs/{presentations → dev/presentations}/simplecov-mcp-presentation.md +28 -22
- data/docs/fixtures/demo_project/README.md +9 -0
- data/docs/{ADVANCED_USAGE.md → user/ADVANCED_USAGE.md} +129 -319
- data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
- data/docs/user/CLI_USAGE.md +750 -0
- data/docs/{ERROR_HANDLING.md → user/ERROR_HANDLING.md} +12 -12
- data/docs/user/EXAMPLES.md +588 -0
- data/docs/user/INSTALLATION.md +130 -0
- data/docs/{LIBRARY_API.md → user/LIBRARY_API.md} +90 -32
- data/docs/{MCP_INTEGRATION.md → user/MCP_INTEGRATION.md} +36 -34
- data/docs/user/README.md +14 -0
- data/docs/{TROUBLESHOOTING.md → user/TROUBLESHOOTING.md} +21 -100
- data/docs/user/V2-BREAKING-CHANGES.md +472 -0
- data/exe/simplecov-mcp +1 -1
- data/lib/simplecov_mcp/{cli_config.rb → app_config.rb} +12 -12
- data/lib/simplecov_mcp/app_context.rb +1 -1
- data/lib/simplecov_mcp/base_tool.rb +66 -38
- data/lib/simplecov_mcp/cli.rb +67 -123
- data/lib/simplecov_mcp/commands/base_command.rb +16 -27
- data/lib/simplecov_mcp/commands/command_factory.rb +8 -2
- data/lib/simplecov_mcp/commands/detailed_command.rb +16 -2
- data/lib/simplecov_mcp/commands/list_command.rb +1 -1
- data/lib/simplecov_mcp/commands/raw_command.rb +18 -2
- data/lib/simplecov_mcp/commands/summary_command.rb +20 -3
- data/lib/simplecov_mcp/commands/totals_command.rb +53 -0
- data/lib/simplecov_mcp/commands/uncovered_command.rb +24 -5
- data/lib/simplecov_mcp/commands/validate_command.rb +60 -0
- data/lib/simplecov_mcp/commands/version_command.rb +19 -4
- data/lib/simplecov_mcp/config_parser.rb +32 -0
- data/lib/simplecov_mcp/constants.rb +3 -3
- data/lib/simplecov_mcp/coverage_reporter.rb +31 -0
- data/lib/simplecov_mcp/error_handler.rb +81 -40
- data/lib/simplecov_mcp/error_handler_factory.rb +2 -2
- data/lib/simplecov_mcp/errors.rb +12 -19
- data/lib/simplecov_mcp/formatters/source_formatter.rb +23 -19
- data/lib/simplecov_mcp/formatters.rb +51 -0
- data/lib/simplecov_mcp/mcp_server.rb +9 -7
- data/lib/simplecov_mcp/mode_detector.rb +6 -5
- data/lib/simplecov_mcp/model.rb +122 -88
- data/lib/simplecov_mcp/option_normalizers.rb +39 -18
- data/lib/simplecov_mcp/option_parser_builder.rb +85 -72
- data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +3 -5
- data/lib/simplecov_mcp/option_parsers/error_helper.rb +18 -17
- data/lib/simplecov_mcp/path_relativizer.rb +17 -14
- data/lib/simplecov_mcp/predicate_evaluator.rb +72 -0
- data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/project_totals_presenter.rb +27 -0
- data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +14 -18
- data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +7 -9
- data/lib/simplecov_mcp/resultset_loader.rb +20 -25
- data/lib/simplecov_mcp/staleness_checker.rb +50 -46
- data/lib/simplecov_mcp/table_formatter.rb +64 -0
- data/lib/simplecov_mcp/tools/all_files_coverage_tool.rb +20 -50
- data/lib/simplecov_mcp/tools/coverage_detailed_tool.rb +13 -7
- data/lib/simplecov_mcp/tools/coverage_raw_tool.rb +12 -7
- data/lib/simplecov_mcp/tools/coverage_summary_tool.rb +13 -8
- data/lib/simplecov_mcp/tools/coverage_table_tool.rb +20 -60
- data/lib/simplecov_mcp/tools/coverage_totals_tool.rb +44 -0
- data/lib/simplecov_mcp/tools/help_tool.rb +38 -66
- data/lib/simplecov_mcp/tools/uncovered_lines_tool.rb +13 -8
- data/lib/simplecov_mcp/tools/validate_tool.rb +72 -0
- data/lib/simplecov_mcp/tools/version_tool.rb +7 -14
- data/lib/simplecov_mcp/util.rb +18 -12
- data/lib/simplecov_mcp/version.rb +1 -1
- data/lib/simplecov_mcp.rb +23 -29
- data/spec/all_files_coverage_tool_spec.rb +4 -3
- data/spec/{cli_config_spec.rb → app_config_spec.rb} +31 -26
- data/spec/base_tool_spec.rb +17 -14
- data/spec/cli/show_default_report_spec.rb +2 -2
- data/spec/cli_enumerated_options_spec.rb +31 -9
- data/spec/cli_error_spec.rb +46 -23
- data/spec/cli_format_spec.rb +123 -0
- data/spec/cli_json_options_spec.rb +50 -0
- data/spec/cli_source_spec.rb +11 -63
- data/spec/cli_spec.rb +82 -97
- data/spec/cli_usage_spec.rb +15 -15
- data/spec/commands/base_command_spec.rb +12 -92
- data/spec/commands/command_factory_spec.rb +7 -3
- data/spec/commands/detailed_command_spec.rb +10 -24
- data/spec/commands/list_command_spec.rb +28 -0
- data/spec/commands/raw_command_spec.rb +43 -20
- data/spec/commands/summary_command_spec.rb +10 -23
- data/spec/commands/totals_command_spec.rb +34 -0
- data/spec/commands/uncovered_command_spec.rb +29 -23
- data/spec/commands/validate_command_spec.rb +213 -0
- data/spec/commands/version_command_spec.rb +38 -0
- data/spec/constants_spec.rb +3 -3
- data/spec/coverage_reporter_spec.rb +102 -0
- data/spec/coverage_table_tool_spec.rb +21 -10
- data/spec/coverage_totals_tool_spec.rb +37 -0
- data/spec/error_handler_spec.rb +120 -4
- data/spec/error_mode_spec.rb +18 -22
- data/spec/errors_edge_cases_spec.rb +101 -28
- data/spec/errors_stale_spec.rb +34 -0
- data/spec/file_based_mcp_tools_spec.rb +6 -6
- data/spec/fixtures/project1/lib/bar.rb +2 -0
- data/spec/fixtures/project1/lib/foo.rb +2 -0
- data/spec/help_tool_spec.rb +2 -18
- data/spec/integration_spec.rb +103 -161
- data/spec/logging_fallback_spec.rb +3 -3
- data/spec/mcp_server_integration_spec.rb +1 -1
- data/spec/mcp_server_spec.rb +70 -53
- data/spec/mode_detector_spec.rb +46 -41
- data/spec/model_error_handling_spec.rb +139 -78
- data/spec/model_staleness_spec.rb +13 -13
- data/spec/option_normalizers_spec.rb +111 -112
- data/spec/option_parsers/env_options_parser_spec.rb +25 -37
- data/spec/option_parsers/error_helper_spec.rb +56 -56
- data/spec/path_relativizer_spec.rb +15 -0
- data/spec/presenters/coverage_detailed_presenter_spec.rb +1 -1
- data/spec/presenters/coverage_summary_presenter_spec.rb +1 -1
- data/spec/presenters/coverage_uncovered_presenter_spec.rb +1 -1
- data/spec/presenters/project_coverage_presenter_spec.rb +9 -8
- data/spec/presenters/project_totals_presenter_spec.rb +144 -0
- data/spec/resolvers/coverage_line_resolver_spec.rb +261 -36
- data/spec/resolvers/resultset_path_resolver_spec.rb +13 -8
- data/spec/shared_examples/file_based_mcp_tools.rb +23 -18
- data/spec/shared_examples/formatted_command_examples.rb +64 -0
- data/spec/simple_cov_mcp_module_spec.rb +24 -3
- data/spec/simplecov_mcp/formatters/source_formatter_spec.rb +267 -0
- data/spec/simplecov_mcp/formatters_spec.rb +76 -0
- data/spec/simplecov_mcp/presenters/base_coverage_presenter_spec.rb +79 -0
- data/spec/simplecov_mcp_model_spec.rb +97 -47
- data/spec/simplecov_mcp_opts_spec.rb +42 -39
- data/spec/spec_helper.rb +27 -92
- data/spec/staleness_checker_spec.rb +10 -9
- data/spec/staleness_more_spec.rb +4 -4
- data/spec/support/cli_helpers.rb +22 -0
- data/spec/support/control_flow_helpers.rb +20 -0
- data/spec/support/fake_mcp.rb +40 -0
- data/spec/support/io_helpers.rb +29 -0
- data/spec/support/mcp_helpers.rb +35 -0
- data/spec/support/mcp_runner.rb +10 -8
- data/spec/support/mocking_helpers.rb +30 -0
- data/spec/table_format_spec.rb +70 -0
- data/spec/tools/validate_tool_spec.rb +132 -0
- data/spec/tools_error_handling_spec.rb +34 -48
- data/spec/util_spec.rb +5 -4
- data/spec/version_spec.rb +7 -7
- data/spec/version_tool_spec.rb +20 -22
- metadata +90 -23
- data/docs/BRANCH_ONLY_COVERAGE.md +0 -81
- data/docs/CLI_USAGE.md +0 -637
- data/docs/EXAMPLES.md +0 -430
- data/docs/INSTALLATION.md +0 -352
- 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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
'lines' => nil,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
'
|
|
44
|
-
'[:
|
|
45
|
-
|
|
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
|
-
|
|
52
|
-
|
|
274
|
+
resolver = described_class.new(cov_data)
|
|
275
|
+
lines = resolver.lookup_lines(path)
|
|
53
276
|
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
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, '
|
|
75
|
+
'summary' => { 'covered' => 10, 'total' => 12, 'percentage' => 83.33 }
|
|
75
76
|
},
|
|
76
|
-
additional_validations: ->(data,
|
|
77
|
-
expect(data['summary']).to include('covered', 'total', '
|
|
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
|
|
84
|
-
|
|
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,
|
|
98
|
+
data, = expect_mcp_text_json(response)
|
|
96
99
|
|
|
97
|
-
expect(data['summary']['
|
|
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, '
|
|
129
|
+
'summary' => { 'covered' => 10, 'total' => 12, 'percentage' => 83.33 }
|
|
127
130
|
},
|
|
128
|
-
additional_validations: ->(data,
|
|
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
|
|
136
|
-
|
|
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,
|
|
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', '
|
|
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, '
|
|
172
|
+
'summary' => { 'covered' => 1, 'total' => 2, 'percentage' => 50.0 }
|
|
168
173
|
},
|
|
169
|
-
additional_validations: ->(data,
|
|
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
|
}
|