simplecov-mcp 0.1.0 → 0.2.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +379 -32
  3. data/exe/simplecov-mcp +19 -2
  4. data/lib/simple_cov/mcp.rb +9 -0
  5. data/lib/simple_cov_mcp/base_tool.rb +54 -0
  6. data/lib/simple_cov_mcp/cli.rb +390 -0
  7. data/lib/simple_cov_mcp/error_handler.rb +131 -0
  8. data/lib/simple_cov_mcp/error_handler_factory.rb +38 -0
  9. data/lib/simple_cov_mcp/errors.rb +176 -0
  10. data/lib/simple_cov_mcp/mcp_server.rb +30 -0
  11. data/lib/simple_cov_mcp/model.rb +104 -0
  12. data/lib/simple_cov_mcp/staleness_checker.rb +125 -0
  13. data/lib/simple_cov_mcp/tools/all_files_coverage_tool.rb +65 -0
  14. data/lib/simple_cov_mcp/tools/coverage_detailed_tool.rb +29 -0
  15. data/lib/simple_cov_mcp/tools/coverage_raw_tool.rb +29 -0
  16. data/lib/simple_cov_mcp/tools/coverage_summary_tool.rb +29 -0
  17. data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +61 -0
  18. data/lib/simple_cov_mcp/tools/help_tool.rb +136 -0
  19. data/lib/simple_cov_mcp/tools/uncovered_lines_tool.rb +29 -0
  20. data/lib/simple_cov_mcp/tools/version_tool.rb +31 -0
  21. data/lib/simple_cov_mcp/util.rb +122 -0
  22. data/lib/simple_cov_mcp/version.rb +5 -0
  23. data/lib/simple_cov_mcp.rb +102 -0
  24. data/lib/simplecov_mcp.rb +2 -3
  25. data/spec/all_files_coverage_tool_spec.rb +46 -0
  26. data/spec/base_tool_spec.rb +58 -0
  27. data/spec/cli_error_spec.rb +103 -0
  28. data/spec/cli_source_spec.rb +37 -0
  29. data/spec/cli_spec.rb +72 -0
  30. data/spec/cli_table_spec.rb +28 -0
  31. data/spec/cli_usage_spec.rb +58 -0
  32. data/spec/coverage_table_tool_spec.rb +64 -0
  33. data/spec/error_handler_spec.rb +72 -0
  34. data/spec/errors_stale_spec.rb +49 -0
  35. data/spec/fixtures/project1/lib/bar.rb +4 -0
  36. data/spec/fixtures/project1/lib/foo.rb +5 -0
  37. data/spec/help_tool_spec.rb +47 -0
  38. data/spec/legacy_shim_spec.rb +13 -0
  39. data/spec/mcp_server_spec.rb +78 -0
  40. data/spec/model_staleness_spec.rb +49 -0
  41. data/spec/simplecov_mcp_model_spec.rb +51 -32
  42. data/spec/spec_helper.rb +37 -7
  43. data/spec/staleness_more_spec.rb +39 -0
  44. data/spec/util_spec.rb +78 -0
  45. data/spec/version_spec.rb +10 -0
  46. metadata +59 -17
  47. data/lib/simplecov/mcp/base_tool.rb +0 -18
  48. data/lib/simplecov/mcp/cli.rb +0 -98
  49. data/lib/simplecov/mcp/model.rb +0 -59
  50. data/lib/simplecov/mcp/tools/all_files_coverage.rb +0 -28
  51. data/lib/simplecov/mcp/tools/coverage_detailed.rb +0 -22
  52. data/lib/simplecov/mcp/tools/coverage_raw.rb +0 -22
  53. data/lib/simplecov/mcp/tools/coverage_summary.rb +0 -22
  54. data/lib/simplecov/mcp/tools/uncovered_lines.rb +0 -22
  55. data/lib/simplecov/mcp/util.rb +0 -94
  56. data/lib/simplecov/mcp/version.rb +0 -8
  57. data/lib/simplecov/mcp.rb +0 -28
data/spec/cli_spec.rb ADDED
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::CoverageCLI do
6
+ let(:root) { (FIXTURES / 'project1').to_s }
7
+
8
+ def run_cli(*argv)
9
+ cli = described_class.new
10
+ silence_output do |out, _err|
11
+ cli.run(argv.flatten)
12
+ return out.string
13
+ end
14
+ end
15
+
16
+ it 'prints summary as JSON for a file' do
17
+ output = run_cli('summary', 'lib/foo.rb', '--json', '--root', root, '--resultset', 'coverage')
18
+ data = JSON.parse(output)
19
+ expect(data['file']).to end_with('lib/foo.rb')
20
+ expect(data['summary']).to include('covered' => 2, 'total' => 3)
21
+ end
22
+
23
+ it 'prints raw lines as JSON' do
24
+ output = run_cli('raw', 'lib/foo.rb', '--json', '--root', root, '--resultset', 'coverage')
25
+ data = JSON.parse(output)
26
+ expect(data['file']).to end_with('lib/foo.rb')
27
+ expect(data['lines']).to eq([1, 0, nil, 2])
28
+ end
29
+
30
+ it 'prints uncovered lines as JSON' do
31
+ output = run_cli('uncovered', 'lib/foo.rb', '--json', '--root', root, '--resultset', 'coverage')
32
+ data = JSON.parse(output)
33
+ expect(data['uncovered']).to eq([2])
34
+ expect(data['summary']).to include('total' => 3)
35
+ end
36
+
37
+ it 'prints detailed rows as JSON' do
38
+ output = run_cli('detailed', 'lib/foo.rb', '--json', '--root', root, '--resultset', 'coverage')
39
+ data = JSON.parse(output)
40
+ expect(data['lines']).to be_an(Array)
41
+ expect(data['lines'].first).to include('line', 'hits', 'covered')
42
+ end
43
+
44
+ it 'lists all files as JSON with sort order' do
45
+ output = run_cli('list', '--json', '--root', root, '--resultset', 'coverage', '--sort-order', 'ascending')
46
+ asc = JSON.parse(output)
47
+ expect(asc['files']).to be_an(Array)
48
+ expect(asc['files'].first['file']).to end_with('lib/bar.rb')
49
+
50
+ # Includes counts for total/ok/stale and they are consistent
51
+ expect(asc['counts']).to include('total', 'ok', 'stale')
52
+ total = asc['counts']['total']
53
+ ok = asc['counts']['ok']
54
+ stale = asc['counts']['stale']
55
+ expect(total).to eq(asc['files'].length)
56
+ expect(ok + stale).to eq(total)
57
+
58
+ output = run_cli('list', '--json', '--root', root, '--resultset', 'coverage', '--sort-order', 'descending')
59
+ desc = JSON.parse(output)
60
+ expect(desc['files'].first['file']).to end_with('lib/foo.rb')
61
+ end
62
+
63
+ it 'exposes expected subcommands via constant' do
64
+ expect(described_class::SUBCOMMANDS).to eq(%w[list summary raw uncovered detailed version])
65
+ end
66
+
67
+ it 'can include source in JSON payload (nil if file missing)' do
68
+ output = run_cli('summary', 'lib/foo.rb', '--json', '--root', root, '--resultset', 'coverage', '--source')
69
+ data = JSON.parse(output)
70
+ expect(data).to have_key('source')
71
+ end
72
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::CoverageCLI do
6
+ let(:root) { (FIXTURES / 'project1').to_s }
7
+
8
+ def run_cli(*argv)
9
+ cli = described_class.new
10
+ silence_output do |out, _err|
11
+ cli.run(argv.flatten)
12
+ return out.string
13
+ end
14
+ end
15
+
16
+ it 'prints default table when no subcommand is given' do
17
+ output = run_cli('--root', root, '--resultset', 'coverage')
18
+
19
+ # Contains a header row and at least one data row with expected columns
20
+ expect(output).to include('File')
21
+ expect(output).to include('Covered')
22
+ expect(output).to include('Total')
23
+
24
+ # Should list fixture files from the demo project
25
+ expect(output).to include('lib/foo.rb')
26
+ expect(output).to include('lib/bar.rb')
27
+ end
28
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::CoverageCLI do
6
+ let(:root) { (FIXTURES / 'project1').to_s }
7
+
8
+ def run_cli_with_status(*argv)
9
+ cli = described_class.new
10
+ status = nil
11
+ out_str = err_str = nil
12
+ silence_output do |out, err|
13
+ begin
14
+ cli.run(argv.flatten)
15
+ status = 0
16
+ rescue SystemExit => e
17
+ status = e.status
18
+ end
19
+ out_str = out.string
20
+ err_str = err.string
21
+ end
22
+ [out_str, err_str, status]
23
+ end
24
+
25
+ it 'errors with usage when summary path is missing' do
26
+ _out, err, status = run_cli_with_status('summary', '--root', root, '--resultset', 'coverage')
27
+ expect(status).to eq(1)
28
+ expect(err).to include('Usage: simplecov-mcp summary <path>')
29
+ end
30
+
31
+ it 'treats unknown subcommand as no subcommand and prints default table' do
32
+ out, err, status = run_cli_with_status('bogus', '--root', root, '--resultset', 'coverage')
33
+ expect(status).to eq(0)
34
+ expect(err).to eq("")
35
+ expect(out).to include('File')
36
+ expect(out).to include('lib/foo.rb')
37
+ end
38
+
39
+ it 'list honors stale=error and tracked_globs by exiting 1 when project is stale' do
40
+ tmp = File.join(root, 'lib', 'brand_new_file_for_cli_usage_spec.rb')
41
+ begin
42
+ File.write(tmp, "# new file\n")
43
+ _out, err, status = run_cli_with_status('list', '--root', root, '--resultset', 'coverage', '--stale', 'error', '--tracked-globs', 'lib/**/*.rb')
44
+ expect(status).to eq(1)
45
+ expect(err).to include('Coverage data stale (project)')
46
+ ensure
47
+ File.delete(tmp) if File.exist?(tmp)
48
+ end
49
+ end
50
+
51
+ it 'list with stale=off prints table and exits 0' do
52
+ out, err, status = run_cli_with_status('list', '--root', root, '--resultset', 'coverage', '--stale', 'off')
53
+ expect(status).to eq(0)
54
+ expect(err).to eq("")
55
+ expect(out).to include('File')
56
+ expect(out).to include('lib/foo.rb')
57
+ end
58
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'simple_cov_mcp/tools/coverage_table_tool'
5
+
6
+ RSpec.describe SimpleCovMcp::Tools::CoverageTableTool do
7
+ let(:root) { (FIXTURES / 'project1').to_s }
8
+ let(:server_context) { instance_double('ServerContext').as_null_object }
9
+
10
+ before do
11
+ stub_const('MCP::Tool::Response', Struct.new(:payload))
12
+ end
13
+
14
+ def run_tool(stale: 'off')
15
+ # Stub the CoverageModel to avoid file system access
16
+ model = instance_double(SimpleCovMcp::CoverageModel)
17
+ allow(SimpleCovMcp::CoverageModel).to receive(:new).and_return(model)
18
+ allow(model).to receive(:all_files).and_return([
19
+ { 'file' => "#{root}/lib/foo.rb", 'percentage' => 100.0, 'covered' => 10, 'total' => 10, 'stale' => false },
20
+ { 'file' => "#{root}/lib/bar.rb", 'percentage' => 50.0, 'covered' => 5, 'total' => 10, 'stale' => true }
21
+ ])
22
+
23
+ response = described_class.call(root: root, stale: stale, server_context: server_context)
24
+ response.payload.first[:text]
25
+ end
26
+
27
+ it 'returns a formatted table as a string' do
28
+ output = run_tool
29
+
30
+ # Contains a header row and at least one data row with expected columns
31
+ expect(output).to include('File')
32
+ expect(output).to include('Covered')
33
+ expect(output).to include('Total')
34
+ # Staleness column header reads 'Stale'
35
+ expect(output).to include(' │ Stale │')
36
+
37
+ # Should list fixture files from the demo project
38
+ expect(output).to include('lib/foo.rb')
39
+ expect(output).to include('lib/bar.rb')
40
+
41
+ # Check for table borders
42
+ expect(output).to include('┌')
43
+ expect(output).to include('│')
44
+ expect(output).to include('└')
45
+
46
+ # Summary counts line appears after the table
47
+ expect(output).to include('Files: total 2, ok 1, stale 1')
48
+ end
49
+
50
+ it 'configures the CLI to enforce stale checking when requested' do
51
+ model = instance_double(SimpleCovMcp::CoverageModel)
52
+ allow(model).to receive(:all_files).and_return([
53
+ { 'file' => "#{root}/lib/foo.rb", 'percentage' => 100.0, 'covered' => 10, 'total' => 10, 'stale' => false }
54
+ ])
55
+ expect(SimpleCovMcp::CoverageModel).to receive(:new).with(
56
+ root: root,
57
+ resultset: nil,
58
+ staleness: 'error',
59
+ tracked_globs: nil
60
+ ).and_return(model)
61
+
62
+ described_class.call(root: root, stale: 'error', server_context: server_context)
63
+ end
64
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::ErrorHandler do
6
+ let(:logger) do
7
+ Class.new do
8
+ attr_reader :messages
9
+ def initialize; @messages = []; end
10
+ def error(msg); @messages << msg; end
11
+ end.new
12
+ end
13
+
14
+ subject(:handler) { described_class.new(log_errors: true, show_stack_traces: false, logger: logger) }
15
+
16
+ it 'maps filesystem errors to friendly custom errors' do
17
+ e = handler.convert_standard_error(Errno::EISDIR.new('Is a directory @ rb_sysopen - a_dir'))
18
+ expect(e).to be_a(SimpleCovMcp::NotAFileError)
19
+
20
+ e = handler.convert_standard_error(Errno::ENOENT.new('No such file or directory @ rb_sysopen - missing.txt'))
21
+ expect(e).to be_a(SimpleCovMcp::FileNotFoundError)
22
+
23
+ e = handler.convert_standard_error(Errno::EACCES.new('Permission denied @ rb_sysopen - secret'))
24
+ expect(e).to be_a(SimpleCovMcp::FilePermissionError)
25
+ end
26
+
27
+ it 'maps JSON::ParserError to CoverageDataError' do
28
+ e = handler.convert_standard_error(JSON::ParserError.new('unexpected token'))
29
+ expect(e).to be_a(SimpleCovMcp::CoverageDataError)
30
+ expect(e.user_friendly_message).to include('Invalid coverage data format')
31
+ end
32
+
33
+ it 'maps ArgumentError by message' do
34
+ e = handler.convert_standard_error(ArgumentError.new('wrong number of arguments (given 1, expected 2)'))
35
+ expect(e).to be_a(SimpleCovMcp::UsageError)
36
+
37
+ e = handler.convert_standard_error(ArgumentError.new('invalid option'))
38
+ expect(e).to be_a(SimpleCovMcp::ConfigurationError)
39
+ end
40
+
41
+ it 'maps NoMethodError to CoverageDataError with helpful info' do
42
+ e = handler.convert_standard_error(NoMethodError.new("undefined method `fetch' for #<Hash:0x123>"))
43
+ expect(e).to be_a(SimpleCovMcp::CoverageDataError)
44
+ expect(e.user_friendly_message).to include('Invalid coverage data structure')
45
+ end
46
+
47
+ it 'maps runtime strings from util to friendly errors' do
48
+ e = handler.convert_standard_error(RuntimeError.new('No coverage entry found for /tmp/foo.rb'))
49
+ expect(e).to be_a(SimpleCovMcp::FileError)
50
+ expect(e.user_friendly_message).to include('No coverage data found for file')
51
+
52
+ e = handler.convert_standard_error(RuntimeError.new('Could not find .resultset.json under /path; run tests'))
53
+ expect(e).to be_a(SimpleCovMcp::CoverageDataError)
54
+ expect(e.user_friendly_message).to include('run your tests first')
55
+
56
+ e = handler.convert_standard_error(RuntimeError.new('No .resultset.json found in directory: /path'))
57
+ expect(e).to be_a(SimpleCovMcp::CoverageDataError)
58
+
59
+ e = handler.convert_standard_error(RuntimeError.new('Specified resultset not found: /nowhere/file.json'))
60
+ expect(e).to be_a(SimpleCovMcp::ResultsetNotFoundError)
61
+ end
62
+
63
+ it 'logs via provided logger' do
64
+ begin
65
+ handler.handle_error(Errno::ENOENT.new('No such file or directory @ rb_sysopen - x'), context: 'test', reraise: false)
66
+ rescue StandardError
67
+ # reraise disabled
68
+ end
69
+ expect(logger.messages.join).to include('Error in test')
70
+ end
71
+ end
72
+
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::CoverageDataStaleError do
6
+ it 'formats a detailed, user-friendly message with UTC/local, delta, and resultset' do
7
+ file_time = Time.at(1_000) # 1970-01-01T00:16:40Z
8
+ cov_epoch = 0 # 1970-01-01T00:00:00Z
9
+ err = described_class.new(
10
+ 'Coverage data appears stale for foo.rb',
11
+ nil,
12
+ file_path: 'foo.rb',
13
+ file_mtime: file_time,
14
+ cov_timestamp: cov_epoch,
15
+ src_len: 10,
16
+ cov_len: 8,
17
+ resultset_path: '/path/to/coverage/.resultset.json'
18
+ )
19
+
20
+ msg = err.user_friendly_message
21
+
22
+ expect(msg).to include('Coverage data stale: Coverage data appears stale for foo.rb')
23
+ expect(msg).to match(/File\s*-\s*time:\s*1970-01-01T00:16:40Z/)
24
+ expect(msg).to include('(local ') # do not assert exact local tz
25
+ expect(msg).to match(/Coverage\s*-\s*time:\s*1970-01-01T00:00:00Z/)
26
+ expect(msg).to match(/lines:\s*10/)
27
+ expect(msg).to match(/lines:\s*8/)
28
+ expect(msg).to match(/Delta\s*- file is \+1000s newer than coverage/)
29
+ expect(msg).to include('Resultset - /path/to/coverage/.resultset.json')
30
+ end
31
+
32
+ it 'handles missing timestamps gracefully' do
33
+ err = described_class.new(
34
+ 'Coverage data appears stale for bar.rb',
35
+ nil,
36
+ file_path: 'bar.rb',
37
+ file_mtime: nil,
38
+ cov_timestamp: nil,
39
+ src_len: 1,
40
+ cov_len: 0,
41
+ resultset_path: nil
42
+ )
43
+ msg = err.user_friendly_message
44
+ expect(msg).to include('Coverage data stale: Coverage data appears stale for bar.rb')
45
+ expect(msg).to match(/File\s*-\s*time:\s*not found.*lines: 1/m)
46
+ expect(msg).to match(/Coverage\s*-\s*time:\s*not found.*lines: 0/m)
47
+ expect(msg).not_to include('Delta')
48
+ end
49
+ end
@@ -0,0 +1,4 @@
1
+ puts 'bar line 1 (uncovered)'
2
+ puts 'bar line 2 (uncovered)'
3
+ puts 'bar line 3'
4
+
@@ -0,0 +1,5 @@
1
+ puts 'foo line 1'
2
+ puts 'foo line 2 (uncovered)'
3
+
4
+ puts 'foo line 4'
5
+
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'simple_cov_mcp/tools/help_tool'
5
+
6
+ RSpec.describe SimpleCovMcp::Tools::HelpTool do
7
+ let(:server_context) { instance_double('ServerContext').as_null_object }
8
+
9
+ before do
10
+ response_class = Class.new do
11
+ attr_reader :payload, :meta
12
+
13
+ def initialize(payload)
14
+ @payload = payload
15
+ @meta = nil
16
+ end
17
+ end
18
+ stub_const('MCP::Tool::Response', response_class)
19
+ end
20
+
21
+ it 'returns guidance for each registered tool' do
22
+ response = described_class.call(server_context: server_context)
23
+ expect(response.meta).to be_nil
24
+
25
+ payload = response.payload.first
26
+ expect(payload['type']).to eq('text')
27
+
28
+ data = JSON.parse(payload['text'])
29
+ tool_names = data['tools'].map { |entry| entry['tool'] }
30
+
31
+ expect(tool_names).to include('coverage_summary_tool', 'uncovered_lines_tool', 'all_files_coverage_tool', 'coverage_table_tool', 'version_tool')
32
+ expect(data['tools']).to all(include('use_when', 'avoid_when', 'inputs', 'example'))
33
+ end
34
+
35
+ it 'filters entries when a query is provided' do
36
+ response = described_class.call(query: 'uncovered', server_context: server_context)
37
+ payload = response.payload.first
38
+ data = JSON.parse(payload['text'])
39
+
40
+ expect(data['tools']).not_to be_empty
41
+ expect(data['tools']).to all(satisfy do |entry|
42
+ combined = [entry['tool'], entry['label'], entry['use_when'], entry['avoid_when']].compact.join(' ').downcase
43
+ combined.include?('uncovered')
44
+ end)
45
+ expect(data['tools'].map { |entry| entry['tool'] }).to include('uncovered_lines_tool')
46
+ end
47
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'Legacy require shim' do
6
+ it "defines SimpleCov::Mcp aliasing SimpleCovMcp" do
7
+ # Ensure the legacy file itself is executed for coverage and behavior
8
+ load File.expand_path('../../lib/simple_cov/mcp.rb', __FILE__)
9
+ expect(defined?(SimpleCov::Mcp)).to be_truthy
10
+ expect(SimpleCov::Mcp).to eq(SimpleCovMcp)
11
+ end
12
+ end
13
+
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::MCPServer do
6
+ # This spec verifies the MCP server boot path without requiring the real
7
+ # MCP runtime. We stub the MCP::Server and its stdio transport to capture
8
+ # constructor parameters and observe that `open` is invoked.
9
+ it 'sets error handler and boots server with expected tools' do
10
+ # Prepare fakes for MCP server and transport
11
+ module ::MCP; end unless defined?(::MCP)
12
+
13
+ # Fake server captures the last created instance so we can assert on the
14
+ # name/version/tools passed in by SimpleCovMcp::MCPServer. The
15
+ # `last_instance` accessor is a class-level handle to the most recently
16
+ # instantiated fake. Because the production code constructs the server
17
+ # internally, we can't grab the instance directly; recording the most
18
+ # recent instance lets the test fetch it after `run` completes.
19
+ fake_server_class = Class.new do
20
+ class << self
21
+ # Holds the most recently created fake server instance so tests can
22
+ # inspect it after the code under test performs internal construction.
23
+ attr_accessor :last_instance
24
+ end
25
+ attr_reader :params
26
+ def initialize(name:, version:, tools:)
27
+ @params = { name: name, version: version, tools: tools }
28
+ self.class.last_instance = self
29
+ end
30
+ end
31
+
32
+ # Fake stdio transport records whether `open` was called and the server
33
+ # it was initialized with, to confirm that the server was started. It also
34
+ # exposes a `last_instance` class accessor for the same reason as above:
35
+ # to retrieve the instance created during `run` so we can assert on it.
36
+ fake_transport_class = Class.new do
37
+ class << self
38
+ # Holds the most recently created fake transport instance for later
39
+ # assertions (e.g., that `open` was invoked).
40
+ attr_accessor :last_instance
41
+ end
42
+ attr_reader :server, :opened
43
+ def initialize(server)
44
+ @server = server
45
+ @opened = false
46
+ self.class.last_instance = self
47
+ end
48
+ def open
49
+ @opened = true
50
+ end
51
+ def opened?
52
+ @opened
53
+ end
54
+ end
55
+
56
+ stub_const('MCP::Server', fake_server_class)
57
+ stub_const('MCP::Server::Transports::StdioTransport', fake_transport_class)
58
+
59
+ server = described_class.new
60
+ # Error handler should be set for MCP server usage (factory selection).
61
+ expect(SimpleCovMcp.error_handler).to be_a(SimpleCovMcp::ErrorHandler)
62
+
63
+ # Run should construct server and open transport
64
+ server.run
65
+ # Fetch the instances created during `run` via the class-level hooks.
66
+ fake_server = fake_server_class.last_instance
67
+ fake_transport = fake_transport_class.last_instance
68
+
69
+ expect(fake_transport).not_to be_nil
70
+ expect(fake_transport).to be_opened
71
+ expect(fake_server).not_to be_nil
72
+
73
+ expect(fake_server.params[:name]).to eq('simplecov-mcp')
74
+ # Ensure expected tools are registered
75
+ tool_names = fake_server.params[:tools].map { |t| t.name.split('::').last }
76
+ expect(tool_names).to include('AllFilesCoverageTool', 'CoverageDetailedTool', 'CoverageRawTool', 'CoverageSummaryTool', 'UncoveredLinesTool', 'HelpTool')
77
+ end
78
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe SimpleCovMcp::CoverageModel do
6
+ let(:root) { (FIXTURES / 'project1').to_s }
7
+
8
+ def with_stubbed_coverage_timestamp(ts) = begin
9
+ allow(SimpleCovMcp::CovUtil).to receive(:latest_timestamp).and_return(ts)
10
+ yield
11
+ end
12
+
13
+ it "raises stale error when staleness mode is 'error' and file is newer" do
14
+ with_stubbed_coverage_timestamp(0) do
15
+ model = described_class.new(root: root, staleness: 'error')
16
+ expect {
17
+ model.summary_for('lib/foo.rb')
18
+ }.to raise_error(SimpleCovMcp::CoverageDataStaleError, /stale/i)
19
+ end
20
+ end
21
+
22
+ it "does not check staleness when mode is 'off'" do
23
+ with_stubbed_coverage_timestamp(0) do
24
+ model = described_class.new(root: root, staleness: 'off')
25
+ expect { model.summary_for('lib/foo.rb') }.not_to raise_error
26
+ end
27
+ end
28
+ it 'all_files raises project-level stale when any source file is newer than coverage' do
29
+ with_stubbed_coverage_timestamp(0) do
30
+ model = described_class.new(root: root, staleness: 'error')
31
+ expect { model.all_files }.to raise_error(SimpleCovMcp::CoverageDataProjectStaleError)
32
+ end
33
+ end
34
+
35
+ it 'all_files detects new files via tracked_globs' do
36
+ with_stubbed_coverage_timestamp(Time.now.to_i) do
37
+ tmp = File.join(root, 'lib', 'brand_new_file.rb')
38
+ begin
39
+ File.write(tmp, "# new file\n")
40
+ model = described_class.new(root: root, staleness: 'error')
41
+ expect {
42
+ model.all_files(tracked_globs: ['lib/**/*.rb'])
43
+ }.to raise_error(SimpleCovMcp::CoverageDataProjectStaleError)
44
+ ensure
45
+ File.delete(tmp) if File.exist?(tmp)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -1,54 +1,73 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
3
+ require 'spec_helper'
4
4
 
5
- RSpec.describe Simplecov::Mcp::CoverageModel do
6
- let(:root) { (FIXTURES / "project1").to_s }
5
+ RSpec.describe SimpleCovMcp::CoverageModel do
6
+ let(:root) { (FIXTURES / 'project1').to_s }
7
7
  subject(:model) { described_class.new(root: root) }
8
8
 
9
- describe "raw_for" do
10
- it "returns absolute file and lines array" do
11
- data = model.raw_for("lib/foo.rb")
12
- expect(data[:file]).to eq(File.expand_path("lib/foo.rb", root))
13
- expect(data[:lines]).to eq([1, 0, nil, 2])
9
+ describe 'raw_for' do
10
+ it 'returns absolute file and lines array' do
11
+ data = model.raw_for('lib/foo.rb')
12
+ expect(data['file']).to eq(File.expand_path('lib/foo.rb', root))
13
+ expect(data['lines']).to eq([1, 0, nil, 2])
14
14
  end
15
15
  end
16
16
 
17
- describe "summary_for" do
18
- it "computes covered/total/pct" do
19
- data = model.summary_for("lib/foo.rb")
20
- expect(data[:summary]["total"]).to eq(3)
21
- expect(data[:summary]["covered"]).to eq(2)
22
- expect(data[:summary]["pct"]).to be_within(0.01).of(66.67)
17
+ describe 'summary_for' do
18
+ it 'computes covered/total/pct' do
19
+ data = model.summary_for('lib/foo.rb')
20
+ expect(data['summary']['total']).to eq(3)
21
+ expect(data['summary']['covered']).to eq(2)
22
+ expect(data['summary']['pct']).to be_within(0.01).of(66.67)
23
23
  end
24
24
  end
25
25
 
26
- describe "uncovered_for" do
27
- it "lists uncovered executable line numbers" do
28
- data = model.uncovered_for("lib/foo.rb")
29
- expect(data[:uncovered]).to eq([2])
30
- expect(data[:summary]["total"]).to eq(3)
26
+ describe 'uncovered_for' do
27
+ it 'lists uncovered executable line numbers' do
28
+ data = model.uncovered_for('lib/foo.rb')
29
+ expect(data['uncovered']).to eq([2])
30
+ expect(data['summary']['total']).to eq(3)
31
31
  end
32
32
  end
33
33
 
34
- describe "detailed_for" do
35
- it "returns per-line details for non-nil lines" do
36
- data = model.detailed_for("lib/foo.rb")
37
- expect(data[:lines]).to eq([
38
- { line: 1, hits: 1, covered: true },
39
- { line: 2, hits: 0, covered: false },
40
- { line: 4, hits: 2, covered: true }
34
+ describe 'detailed_for' do
35
+ it 'returns per-line details for non-nil lines' do
36
+ data = model.detailed_for('lib/foo.rb')
37
+ expect(data['lines']).to eq([
38
+ { 'line' => 1, 'hits' => 1, 'covered' => true },
39
+ { 'line' => 2, 'hits' => 0, 'covered' => false },
40
+ { 'line' => 4, 'hits' => 2, 'covered' => true }
41
41
  ])
42
42
  end
43
43
  end
44
44
 
45
- describe "all_files" do
46
- it "sorts ascending by percentage then by file path" do
45
+ describe 'all_files' do
46
+ it 'sorts ascending by percentage then by file path' do
47
47
  files = model.all_files(sort_order: :ascending)
48
- expect(files.first[:file]).to eq(File.expand_path("lib/bar.rb", root))
49
- expect(files.first[:percentage]).to be_within(0.01).of(33.33)
50
- expect(files.last[:file]).to eq(File.expand_path("lib/foo.rb", root))
48
+ expect(files.first['file']).to eq(File.expand_path('lib/bar.rb', root))
49
+ expect(files.first['percentage']).to be_within(0.01).of(33.33)
50
+ expect(files.last['file']).to eq(File.expand_path('lib/foo.rb', root))
51
51
  end
52
52
  end
53
- end
54
53
 
54
+ describe 'resultset directory handling' do
55
+ it 'accepts a directory containing .resultset.json' do
56
+ model = described_class.new(root: root, resultset: 'coverage')
57
+ data = model.summary_for('lib/foo.rb')
58
+ expect(data['summary']['total']).to eq(3)
59
+ expect(data['summary']['covered']).to eq(2)
60
+ end
61
+
62
+ it 'uses SIMPLECOV_RESULTSET when it is a directory' do
63
+ begin
64
+ ENV['SIMPLECOV_RESULTSET'] = 'coverage'
65
+ model = described_class.new(root: root)
66
+ data = model.summary_for('lib/foo.rb')
67
+ expect(data['summary']['covered']).to eq(2)
68
+ ensure
69
+ ENV.delete('SIMPLECOV_RESULTSET')
70
+ end
71
+ end
72
+ end
73
+ end