simplecov-mcp 0.1.0 → 0.2.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 (58) 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 +63 -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 +133 -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_json_source_spec.rb +92 -0
  29. data/spec/cli_source_spec.rb +37 -0
  30. data/spec/cli_spec.rb +72 -0
  31. data/spec/cli_table_spec.rb +28 -0
  32. data/spec/cli_usage_spec.rb +58 -0
  33. data/spec/coverage_table_tool_spec.rb +64 -0
  34. data/spec/error_handler_spec.rb +72 -0
  35. data/spec/errors_stale_spec.rb +49 -0
  36. data/spec/fixtures/project1/lib/bar.rb +4 -0
  37. data/spec/fixtures/project1/lib/foo.rb +5 -0
  38. data/spec/help_tool_spec.rb +47 -0
  39. data/spec/legacy_shim_spec.rb +13 -0
  40. data/spec/mcp_server_spec.rb +78 -0
  41. data/spec/model_staleness_spec.rb +49 -0
  42. data/spec/simplecov_mcp_model_spec.rb +51 -32
  43. data/spec/spec_helper.rb +37 -7
  44. data/spec/staleness_more_spec.rb +39 -0
  45. data/spec/util_spec.rb +78 -0
  46. data/spec/version_spec.rb +10 -0
  47. metadata +56 -13
  48. data/lib/simplecov/mcp/base_tool.rb +0 -18
  49. data/lib/simplecov/mcp/cli.rb +0 -98
  50. data/lib/simplecov/mcp/model.rb +0 -59
  51. data/lib/simplecov/mcp/tools/all_files_coverage.rb +0 -28
  52. data/lib/simplecov/mcp/tools/coverage_detailed.rb +0 -22
  53. data/lib/simplecov/mcp/tools/coverage_raw.rb +0 -22
  54. data/lib/simplecov/mcp/tools/coverage_summary.rb +0 -22
  55. data/lib/simplecov/mcp/tools/uncovered_lines.rb +0 -22
  56. data/lib/simplecov/mcp/util.rb +0 -94
  57. data/lib/simplecov/mcp/version.rb +0 -8
  58. data/lib/simplecov/mcp.rb +0 -28
@@ -0,0 +1,92 @@
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_json(*argv)
9
+ cli = described_class.new
10
+ out = nil
11
+ silence_output do |stdout, _stderr|
12
+ cli.run(argv.flatten)
13
+ out = stdout.string
14
+ end
15
+ JSON.parse(out)
16
+ end
17
+
18
+ it 'includes source rows in JSON for summary --source=full' do
19
+ data = run_cli_json('summary', 'lib/foo.rb', '--root', root, '--resultset', 'coverage', '--json', '--source=full')
20
+ expect(data['file']).to eq('lib/foo.rb')
21
+ expect(data['source']).to be_an(Array)
22
+ expect(data['source'].first).to include('line', 'code', 'hits', 'covered')
23
+ end
24
+
25
+ it 'includes source rows in JSON for uncovered --source=uncovered' do
26
+ data = run_cli_json('uncovered', 'lib/foo.rb', '--root', root, '--resultset', 'coverage', '--json', '--source=uncovered', '--source-context', '1')
27
+ expect(data['file']).to eq('lib/foo.rb')
28
+ expect(data['source']).to be_an(Array)
29
+ # Only a subset of lines around uncovered should appear
30
+ lines = data['source'].map { |h| h['line'] }
31
+ expect(lines).to include(2) # the uncovered line
32
+ end
33
+
34
+ it 'includes source rows in JSON for detailed --source=full' do
35
+ data = run_cli_json('detailed', 'lib/foo.rb', '--root', root, '--resultset', 'coverage', '--json', '--source=full')
36
+ expect(data['file']).to eq('lib/foo.rb')
37
+ expect(data['lines']).to be_an(Array)
38
+ expect(data['source']).to be_an(Array)
39
+ end
40
+
41
+ it 'renders uncovered source with various context sizes without error' do
42
+ [0, -5, 50].each do |ctx|
43
+ out, err, status = begin
44
+ cli = described_class.new
45
+ s = nil
46
+ o = e = nil
47
+ silence_output do |stdout, stderr|
48
+ begin
49
+ cli.run(['uncovered', 'lib/foo.rb', '--root', root, '--resultset', 'coverage', '--source=uncovered', '--source-context', ctx.to_s, '--no-color'])
50
+ s = 0
51
+ rescue SystemExit => ex
52
+ s = ex.status
53
+ end
54
+ o = stdout.string
55
+ e = stderr.string
56
+ end
57
+ [o, e, s]
58
+ end
59
+ expect(status).to eq(0)
60
+ expect(err).to eq("")
61
+ expect(out).to include('File: lib/foo.rb')
62
+ expect(out).to include('Uncovered lines: 2')
63
+ end
64
+ end
65
+
66
+ it 'respects --color and --no-color for source rendering' do
67
+ # Force color on
68
+ out_color = begin
69
+ cli = described_class.new
70
+ silence_output do |stdout, _stderr|
71
+ cli.run(['summary', 'lib/foo.rb', '--root', root, '--resultset', 'coverage', '--source', '--color'])
72
+ stdout.string
73
+ end
74
+ end
75
+ # If source table is rendered, it should contain ANSI escapes when color is on
76
+ if out_color.include?('Line') && out_color.include?('|')
77
+ expect(out_color).to match(/\e\[\d+m/)
78
+ else
79
+ expect(out_color).to include('[source not available]')
80
+ end
81
+
82
+ # Force color off: expect no ANSI sequences
83
+ out_nocolor = begin
84
+ cli = described_class.new
85
+ silence_output do |stdout, _stderr|
86
+ cli.run(['summary', 'lib/foo.rb', '--root', root, '--resultset', 'coverage', '--source', '--no-color'])
87
+ stdout.string
88
+ end
89
+ end
90
+ expect(out_nocolor).not_to match(/\e\[\d+m/)
91
+ end
92
+ end
@@ -0,0 +1,37 @@
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 'renders uncovered source without error for fixture file' do
26
+ out, err, status = run_cli_with_status(
27
+ 'uncovered', 'lib/foo.rb', '--root', root, '--resultset', 'coverage',
28
+ '--source=uncovered', '--source-context', '1', '--no-color'
29
+ )
30
+ expect(status).to eq(0)
31
+ expect(err).to eq("")
32
+ expect(out).to include('File: lib/foo.rb')
33
+ expect(out).to include('Uncovered lines: 2')
34
+ # Accept either rendered source table or fallback message
35
+ expect(out).to satisfy { |s| s.include?('Line') || s.include?('[source not available]') }
36
+ end
37
+ end
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, meta: nil)
14
+ @payload = payload
15
+ @meta = meta
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[:mimeType]).to eq('application/json')
24
+
25
+ payload = response.payload.first
26
+ expect(payload[:type]).to eq('json')
27
+
28
+ data = payload[:json]
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 = payload[:json]
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