simplecov-mcp 0.3.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +173 -356
- data/docs/ADVANCED_USAGE.md +967 -0
- data/docs/ARCHITECTURE.md +79 -0
- data/docs/BRANCH_ONLY_COVERAGE.md +81 -0
- data/docs/CLI_USAGE.md +637 -0
- data/docs/DEVELOPMENT.md +82 -0
- data/docs/ERROR_HANDLING.md +93 -0
- data/docs/EXAMPLES.md +430 -0
- data/docs/INSTALLATION.md +352 -0
- data/docs/LIBRARY_API.md +635 -0
- data/docs/MCP_INTEGRATION.md +488 -0
- data/docs/TROUBLESHOOTING.md +276 -0
- data/docs/arch-decisions/001-x-arch-decision.md +93 -0
- data/docs/arch-decisions/002-x-arch-decision.md +157 -0
- data/docs/arch-decisions/003-x-arch-decision.md +163 -0
- data/docs/arch-decisions/004-x-arch-decision.md +199 -0
- data/docs/arch-decisions/005-x-arch-decision.md +187 -0
- data/docs/arch-decisions/README.md +60 -0
- data/docs/presentations/simplecov-mcp-presentation.md +249 -0
- data/exe/simplecov-mcp +4 -4
- data/lib/simplecov_mcp/app_context.rb +26 -0
- data/lib/simplecov_mcp/base_tool.rb +74 -0
- data/lib/simplecov_mcp/cli.rb +234 -0
- data/lib/simplecov_mcp/cli_config.rb +56 -0
- data/lib/simplecov_mcp/commands/base_command.rb +78 -0
- data/lib/simplecov_mcp/commands/command_factory.rb +39 -0
- data/lib/simplecov_mcp/commands/detailed_command.rb +24 -0
- data/lib/simplecov_mcp/commands/list_command.rb +13 -0
- data/lib/simplecov_mcp/commands/raw_command.rb +22 -0
- data/lib/simplecov_mcp/commands/summary_command.rb +24 -0
- data/lib/simplecov_mcp/commands/uncovered_command.rb +26 -0
- data/lib/simplecov_mcp/commands/version_command.rb +18 -0
- data/lib/simplecov_mcp/constants.rb +22 -0
- data/lib/simplecov_mcp/error_handler.rb +124 -0
- data/lib/simplecov_mcp/error_handler_factory.rb +31 -0
- data/lib/simplecov_mcp/errors.rb +179 -0
- data/lib/simplecov_mcp/formatters/source_formatter.rb +148 -0
- data/lib/simplecov_mcp/mcp_server.rb +40 -0
- data/lib/simplecov_mcp/mode_detector.rb +55 -0
- data/lib/simplecov_mcp/model.rb +300 -0
- data/lib/simplecov_mcp/option_normalizers.rb +92 -0
- data/lib/simplecov_mcp/option_parser_builder.rb +134 -0
- data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +50 -0
- data/lib/simplecov_mcp/option_parsers/error_helper.rb +109 -0
- data/lib/simplecov_mcp/path_relativizer.rb +61 -0
- data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +44 -0
- data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +52 -0
- data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +126 -0
- data/lib/simplecov_mcp/resolvers/resolver_factory.rb +28 -0
- data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +78 -0
- data/lib/simplecov_mcp/resultset_loader.rb +136 -0
- data/lib/simplecov_mcp/staleness_checker.rb +243 -0
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/all_files_coverage_tool.rb +31 -13
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_detailed_tool.rb +7 -7
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_raw_tool.rb +7 -7
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_summary_tool.rb +7 -7
- data/lib/simplecov_mcp/tools/coverage_table_tool.rb +90 -0
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/help_tool.rb +13 -4
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/uncovered_lines_tool.rb +7 -7
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/version_tool.rb +11 -3
- data/lib/simplecov_mcp/util.rb +82 -0
- data/lib/{simple_cov_mcp → simplecov_mcp}/version.rb +1 -1
- data/lib/simplecov_mcp.rb +144 -2
- data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
- data/spec/TIMESTAMPS.md +48 -0
- data/spec/all_files_coverage_tool_spec.rb +29 -25
- data/spec/base_tool_spec.rb +11 -10
- data/spec/cli/show_default_report_spec.rb +33 -0
- data/spec/cli_config_spec.rb +137 -0
- data/spec/cli_enumerated_options_spec.rb +68 -0
- data/spec/cli_error_spec.rb +105 -47
- data/spec/cli_source_spec.rb +82 -23
- data/spec/cli_spec.rb +140 -5
- data/spec/cli_success_predicate_spec.rb +141 -0
- data/spec/cli_table_spec.rb +1 -1
- data/spec/cli_usage_spec.rb +10 -26
- data/spec/commands/base_command_spec.rb +187 -0
- data/spec/commands/command_factory_spec.rb +72 -0
- data/spec/commands/detailed_command_spec.rb +48 -0
- data/spec/commands/raw_command_spec.rb +46 -0
- data/spec/commands/summary_command_spec.rb +47 -0
- data/spec/commands/uncovered_command_spec.rb +49 -0
- data/spec/constants_spec.rb +61 -0
- data/spec/coverage_table_tool_spec.rb +17 -33
- data/spec/error_handler_spec.rb +22 -13
- data/spec/error_mode_spec.rb +143 -0
- data/spec/errors_edge_cases_spec.rb +239 -0
- data/spec/errors_stale_spec.rb +2 -2
- data/spec/file_based_mcp_tools_spec.rb +99 -0
- data/spec/fixtures/project1/lib/bar.rb +0 -1
- data/spec/fixtures/project1/lib/foo.rb +0 -1
- data/spec/help_tool_spec.rb +11 -17
- data/spec/integration_spec.rb +845 -0
- data/spec/logging_fallback_spec.rb +128 -0
- data/spec/mcp_logging_spec.rb +44 -0
- data/spec/mcp_server_integration_spec.rb +23 -0
- data/spec/mcp_server_spec.rb +15 -4
- data/spec/mode_detector_spec.rb +148 -0
- data/spec/model_error_handling_spec.rb +210 -0
- data/spec/model_staleness_spec.rb +40 -10
- data/spec/option_normalizers_spec.rb +204 -0
- data/spec/option_parsers/env_options_parser_spec.rb +233 -0
- data/spec/option_parsers/error_helper_spec.rb +222 -0
- data/spec/path_relativizer_spec.rb +83 -0
- data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
- data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
- data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
- data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
- data/spec/presenters/project_coverage_presenter_spec.rb +86 -0
- data/spec/resolvers/coverage_line_resolver_spec.rb +57 -0
- data/spec/resolvers/resolver_factory_spec.rb +61 -0
- data/spec/resolvers/resultset_path_resolver_spec.rb +55 -0
- data/spec/resultset_loader_spec.rb +167 -0
- data/spec/shared_examples/README.md +115 -0
- data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
- data/spec/shared_examples/file_based_mcp_tools.rb +174 -0
- data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
- data/spec/simple_cov_mcp_module_spec.rb +16 -0
- data/spec/simplecov_mcp_model_spec.rb +340 -9
- data/spec/simplecov_mcp_opts_spec.rb +182 -0
- data/spec/spec_helper.rb +147 -4
- data/spec/staleness_checker_spec.rb +373 -0
- data/spec/staleness_more_spec.rb +16 -13
- data/spec/support/mcp_runner.rb +64 -0
- data/spec/tools_error_handling_spec.rb +144 -0
- data/spec/util_spec.rb +109 -34
- data/spec/version_spec.rb +117 -9
- data/spec/version_tool_spec.rb +131 -10
- metadata +120 -63
- data/lib/simple_cov/mcp.rb +0 -9
- data/lib/simple_cov_mcp/base_tool.rb +0 -70
- data/lib/simple_cov_mcp/cli.rb +0 -390
- data/lib/simple_cov_mcp/error_handler.rb +0 -131
- data/lib/simple_cov_mcp/error_handler_factory.rb +0 -38
- data/lib/simple_cov_mcp/errors.rb +0 -176
- data/lib/simple_cov_mcp/mcp_server.rb +0 -30
- data/lib/simple_cov_mcp/model.rb +0 -104
- data/lib/simple_cov_mcp/staleness_checker.rb +0 -125
- data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +0 -61
- data/lib/simple_cov_mcp/util.rb +0 -122
- data/lib/simple_cov_mcp.rb +0 -102
- data/spec/coverage_detailed_tool_spec.rb +0 -36
- data/spec/coverage_raw_tool_spec.rb +0 -32
- data/spec/coverage_summary_tool_spec.rb +0 -39
- data/spec/legacy_shim_spec.rb +0 -13
- data/spec/uncovered_lines_tool_spec.rb +0 -33
data/spec/spec_helper.rb
CHANGED
@@ -12,21 +12,82 @@ rescue LoadError
|
|
12
12
|
warn 'SimpleCov not available; skipping coverage'
|
13
13
|
end
|
14
14
|
|
15
|
-
ENV.delete('SIMPLECOV_RESULTSET')
|
16
15
|
|
17
16
|
require 'rspec'
|
18
17
|
require 'pathname'
|
19
18
|
require 'json'
|
20
19
|
|
21
|
-
require '
|
20
|
+
require 'simplecov_mcp'
|
22
21
|
|
23
|
-
|
22
|
+
FIXTURES_DIR = Pathname.new(File.expand_path('fixtures', __dir__))
|
23
|
+
|
24
|
+
# Test timestamp constants for consistent and documented test data
|
25
|
+
# Main fixture coverage timestamp: 1720000000 = 2024-07-03 16:26:40 UTC
|
26
|
+
# This represents when the coverage data in spec/fixtures/project1/coverage/.resultset.json was "generated"
|
27
|
+
FIXTURE_COVERAGE_TIMESTAMP = 1_720_000_000
|
28
|
+
|
29
|
+
# Very old timestamp: 0 = 1970-01-01 00:00:00 UTC (Unix epoch)
|
30
|
+
# Used in tests to simulate stale coverage (much older than any real file)
|
31
|
+
VERY_OLD_TIMESTAMP = 0
|
32
|
+
|
33
|
+
# Test timestamps for stale error formatting tests
|
34
|
+
# 1000 = 1970-01-01 00:16:40 UTC (16 minutes and 40 seconds after epoch)
|
35
|
+
TEST_FILE_TIMESTAMP = 1_000
|
36
|
+
|
37
|
+
# Regex pattern for matching ISO 8601 timestamps with brackets in log output
|
38
|
+
# Used to verify log timestamps in tests
|
39
|
+
TIMESTAMP_REGEX = /\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}\]/
|
40
|
+
|
41
|
+
# Helper method to mock resultset file reading with fake coverage data
|
42
|
+
# @param root [String] The test root directory
|
43
|
+
# @param timestamp [Integer] The timestamp to use in the fake resultset
|
44
|
+
# @param coverage [Hash] Optional custom coverage data (default: basic foo.rb and bar.rb)
|
45
|
+
def mock_resultset_with_timestamp(root, timestamp, coverage: nil)
|
46
|
+
mock_resultset_with_metadata(root, { 'timestamp' => timestamp }, coverage: coverage)
|
47
|
+
end
|
48
|
+
|
49
|
+
def mock_resultset_with_created_at(root, created_at, coverage: nil)
|
50
|
+
mock_resultset_with_metadata(root, { 'created_at' => created_at }, coverage: coverage)
|
51
|
+
end
|
52
|
+
|
53
|
+
def mock_resultset_with_metadata(root, metadata, coverage: nil)
|
54
|
+
abs_root = File.absolute_path(root)
|
55
|
+
default_coverage = {
|
56
|
+
File.join(root, 'lib', 'foo.rb') => { 'lines' => [1, 0, nil, 2] },
|
57
|
+
File.join(root, 'lib', 'bar.rb') => { 'lines' => [0, 0, 1] }
|
58
|
+
}
|
59
|
+
|
60
|
+
fake_resultset = {
|
61
|
+
'RSpec' => {
|
62
|
+
'coverage' => coverage || default_coverage
|
63
|
+
}.merge(metadata)
|
64
|
+
}
|
65
|
+
|
66
|
+
allow(File).to receive(:read).and_call_original
|
67
|
+
allow(File).to receive(:read).with(end_with('.resultset.json')).and_return(fake_resultset.to_json)
|
68
|
+
allow(SimpleCovMcp::CovUtil).to receive(:find_resultset)
|
69
|
+
.and_wrap_original do |method, search_root, resultset: nil|
|
70
|
+
if File.absolute_path(search_root) == abs_root && (resultset.nil? || resultset.to_s.empty?)
|
71
|
+
File.join(abs_root, 'coverage', '.resultset.json')
|
72
|
+
else
|
73
|
+
method.call(search_root, resultset: resultset)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# Automatically require all files in spec/support and spec/shared_examples
|
79
|
+
Dir[File.join(__dir__, 'support', '**', '*.rb')].sort.each { |f| require f }
|
80
|
+
Dir[File.join(__dir__, 'shared_examples', '**', '*.rb')].sort.each { |f| require f }
|
24
81
|
|
25
82
|
RSpec.configure do |config|
|
26
83
|
config.example_status_persistence_file_path = '.rspec_status'
|
27
84
|
config.disable_monkey_patching!
|
28
|
-
config.order = :
|
85
|
+
config.order = :defined
|
29
86
|
Kernel.srand config.seed
|
87
|
+
|
88
|
+
# Suppress logging during tests by redirecting to a null device
|
89
|
+
SimpleCovMcp.default_log_file = File::NULL
|
90
|
+
SimpleCovMcp.active_log_file = File::NULL
|
30
91
|
end
|
31
92
|
|
32
93
|
# Shared test helpers
|
@@ -42,8 +103,90 @@ module TestIOHelpers
|
|
42
103
|
$stdout = original_stdout
|
43
104
|
$stderr = original_stderr
|
44
105
|
end
|
106
|
+
|
107
|
+
# Stub staleness checking to return a specific value
|
108
|
+
# @param value [String, false] The staleness value to return ('L', 'T', 'M', or false)
|
109
|
+
def stub_staleness_check(value)
|
110
|
+
checker_double = instance_double(SimpleCovMcp::StalenessChecker)
|
111
|
+
allow(checker_double).to receive_messages(stale_for_file?: value, off?: false)
|
112
|
+
allow(checker_double).to receive(:check_file!)
|
113
|
+
allow(SimpleCovMcp::StalenessChecker).to receive(:new).and_return(checker_double)
|
114
|
+
end
|
45
115
|
end
|
46
116
|
|
117
|
+
# CLI test helpers
|
118
|
+
module CLITestHelpers
|
119
|
+
# Run CLI with the given arguments and return [stdout, stderr, exit_status]
|
120
|
+
def run_cli_with_status(*argv)
|
121
|
+
cli = SimpleCovMcp::CoverageCLI.new
|
122
|
+
status = nil
|
123
|
+
out_str = err_str = nil
|
124
|
+
silence_output do |out, err|
|
125
|
+
begin
|
126
|
+
cli.run(argv.flatten)
|
127
|
+
status = 0
|
128
|
+
rescue SystemExit => e
|
129
|
+
status = e.status
|
130
|
+
end
|
131
|
+
out_str = out.string
|
132
|
+
err_str = err.string
|
133
|
+
end
|
134
|
+
[out_str, err_str, status]
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
# MCP Tool shared examples and helpers
|
139
|
+
module MCPToolTestHelpers
|
140
|
+
def setup_mcp_response_stub
|
141
|
+
# Standardized MCP::Tool::Response stub that works for all tools
|
142
|
+
response_class = Class.new do
|
143
|
+
attr_reader :payload, :meta
|
144
|
+
|
145
|
+
def initialize(payload, meta: nil)
|
146
|
+
@payload = payload
|
147
|
+
@meta = meta
|
148
|
+
end
|
149
|
+
end
|
150
|
+
stub_const('MCP::Tool::Response', response_class)
|
151
|
+
end
|
152
|
+
|
153
|
+
def expect_mcp_text_json(response, expected_keys: [])
|
154
|
+
item = response.payload.first
|
155
|
+
|
156
|
+
# Check for a 'text' part
|
157
|
+
expect(item['type']).to eq('text')
|
158
|
+
expect(item).to have_key('text')
|
159
|
+
|
160
|
+
# Parse and validate JSON content
|
161
|
+
data = JSON.parse(item['text'])
|
162
|
+
|
163
|
+
# Check for expected keys
|
164
|
+
expected_keys.each do |key|
|
165
|
+
expect(data).to have_key(key)
|
166
|
+
end
|
167
|
+
|
168
|
+
[data, item] # Return for additional custom assertions
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
|
173
|
+
|
47
174
|
RSpec.configure do |config|
|
48
175
|
config.include TestIOHelpers
|
176
|
+
config.include CLITestHelpers
|
177
|
+
config.include MCPToolTestHelpers
|
178
|
+
end
|
179
|
+
|
180
|
+
# Custom matchers
|
181
|
+
RSpec::Matchers.define :show_source_table_or_fallback do
|
182
|
+
match do |output|
|
183
|
+
has_table_header = output.match?(/(^|\n)\s*Line\s+\|\s+Source/)
|
184
|
+
has_fallback = output.include?('[source not available]')
|
185
|
+
has_table_header || has_fallback
|
186
|
+
end
|
187
|
+
|
188
|
+
failure_message do |output|
|
189
|
+
"expected output to include a source table header (e.g., 'Line | Source') " \
|
190
|
+
"or the fallback '[source not available]'"
|
191
|
+
end
|
49
192
|
end
|
@@ -0,0 +1,373 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'tmpdir'
|
4
|
+
require 'fileutils'
|
5
|
+
|
6
|
+
RSpec.describe SimpleCovMcp::StalenessChecker do
|
7
|
+
let(:tmpdir) { Dir.mktmpdir('scmcp-stale') }
|
8
|
+
after { FileUtils.remove_entry(tmpdir) if tmpdir && File.directory?(tmpdir) }
|
9
|
+
|
10
|
+
def write_file(path, lines)
|
11
|
+
FileUtils.mkdir_p(File.dirname(path))
|
12
|
+
File.open(path, 'w') { |f| lines.each { |l| f.puts(l) } }
|
13
|
+
end
|
14
|
+
|
15
|
+
shared_examples 'a staleness check' do |
|
16
|
+
description:,
|
17
|
+
file_lines:,
|
18
|
+
coverage_lines:,
|
19
|
+
timestamp:,
|
20
|
+
expected_details:,
|
21
|
+
expected_stale_char:,
|
22
|
+
expected_error:
|
23
|
+
|
|
24
|
+
it description do
|
25
|
+
file = File.join(tmpdir, 'lib', 'test.rb')
|
26
|
+
write_file(file, file_lines) if file_lines
|
27
|
+
|
28
|
+
ts = if timestamp == :past
|
29
|
+
now = Time.now
|
30
|
+
past = Time.at(now.to_i - 3600)
|
31
|
+
File.utime(past, past, file)
|
32
|
+
now
|
33
|
+
else
|
34
|
+
timestamp
|
35
|
+
end
|
36
|
+
|
37
|
+
checker = described_class.new(root: tmpdir, resultset: nil, mode: 'error',
|
38
|
+
tracked_globs: nil, timestamp: ts)
|
39
|
+
|
40
|
+
details = checker.send(:compute_file_staleness_details, file, coverage_lines)
|
41
|
+
|
42
|
+
expected_details.each do |key, value|
|
43
|
+
if value == :any
|
44
|
+
expect(details).to have_key(key)
|
45
|
+
else
|
46
|
+
expect(details[key]).to eq(value)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
expect(checker.stale_for_file?(file, coverage_lines)).to eq(expected_stale_char)
|
51
|
+
|
52
|
+
if expected_error
|
53
|
+
expect { checker.check_file!(file, coverage_lines) }.to raise_error(expected_error)
|
54
|
+
else
|
55
|
+
expect { checker.check_file!(file, coverage_lines) }.not_to raise_error
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
context 'compute_file_staleness_details' do
|
61
|
+
include_examples 'a staleness check',
|
62
|
+
description: 'detects newer file vs coverage timestamp',
|
63
|
+
file_lines: ['a', 'b'],
|
64
|
+
coverage_lines: [1, 1],
|
65
|
+
timestamp: Time.at(Time.now.to_i - 3600),
|
66
|
+
expected_details: {
|
67
|
+
exists: true,
|
68
|
+
cov_len: 2,
|
69
|
+
src_len: 2,
|
70
|
+
newer: true,
|
71
|
+
len_mismatch: false,
|
72
|
+
file_mtime: :any,
|
73
|
+
coverage_timestamp: :any
|
74
|
+
},
|
75
|
+
expected_stale_char: 'T',
|
76
|
+
expected_error: SimpleCovMcp::CoverageDataStaleError
|
77
|
+
|
78
|
+
include_examples 'a staleness check',
|
79
|
+
description: 'detects length mismatch between source and coverage',
|
80
|
+
file_lines: ['a', 'b', 'c', 'd'],
|
81
|
+
coverage_lines: [1, 1],
|
82
|
+
timestamp: Time.now,
|
83
|
+
expected_details: {
|
84
|
+
exists: true,
|
85
|
+
cov_len: 2,
|
86
|
+
src_len: 4,
|
87
|
+
newer: false,
|
88
|
+
len_mismatch: true,
|
89
|
+
file_mtime: :any,
|
90
|
+
coverage_timestamp: :any
|
91
|
+
},
|
92
|
+
expected_stale_char: 'L',
|
93
|
+
expected_error: SimpleCovMcp::CoverageDataStaleError
|
94
|
+
|
95
|
+
include_examples 'a staleness check',
|
96
|
+
description: 'treats missing file as stale',
|
97
|
+
file_lines: nil,
|
98
|
+
coverage_lines: [1, 1, 1],
|
99
|
+
timestamp: Time.now,
|
100
|
+
expected_details: {
|
101
|
+
exists: false,
|
102
|
+
newer: false,
|
103
|
+
len_mismatch: true,
|
104
|
+
file_mtime: nil,
|
105
|
+
coverage_timestamp: :any
|
106
|
+
},
|
107
|
+
expected_stale_char: 'M',
|
108
|
+
expected_error: SimpleCovMcp::CoverageDataStaleError
|
109
|
+
|
110
|
+
include_examples 'a staleness check',
|
111
|
+
description: 'is not stale when timestamps and lengths match',
|
112
|
+
file_lines: ['a', 'b', 'c'],
|
113
|
+
coverage_lines: [1, 0, nil],
|
114
|
+
timestamp: :past,
|
115
|
+
expected_details: {
|
116
|
+
exists: true,
|
117
|
+
newer: false,
|
118
|
+
len_mismatch: false,
|
119
|
+
file_mtime: :any,
|
120
|
+
coverage_timestamp: :any
|
121
|
+
},
|
122
|
+
expected_stale_char: false,
|
123
|
+
expected_error: nil
|
124
|
+
end
|
125
|
+
|
126
|
+
context 'missing_trailing_newline? edge cases' do
|
127
|
+
let(:checker) do
|
128
|
+
described_class.new(root: tmpdir, resultset: nil, mode: 'off', timestamp: Time.now)
|
129
|
+
end
|
130
|
+
|
131
|
+
it 'detects file without trailing newline' do
|
132
|
+
file = File.join(tmpdir, 'no_newline.rb')
|
133
|
+
File.write(file, 'line1')
|
134
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be true
|
135
|
+
end
|
136
|
+
|
137
|
+
it 'detects file with trailing newline (LF)' do
|
138
|
+
file = File.join(tmpdir, 'with_newline.rb')
|
139
|
+
File.write(file, "line1\n")
|
140
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be false
|
141
|
+
end
|
142
|
+
|
143
|
+
it 'handles file with CRLF endings (Windows-style)' do
|
144
|
+
file = File.join(tmpdir, 'crlf.rb')
|
145
|
+
File.write(file, "line1\r\nline2\r\n", mode: 'wb')
|
146
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be false
|
147
|
+
end
|
148
|
+
|
149
|
+
it 'handles file ending with CRLF but no final newline' do
|
150
|
+
file = File.join(tmpdir, 'crlf_no_final.rb')
|
151
|
+
File.write(file, "line1\r\nline2", mode: 'wb')
|
152
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be true
|
153
|
+
end
|
154
|
+
|
155
|
+
it 'handles empty file' do
|
156
|
+
file = File.join(tmpdir, 'empty.rb')
|
157
|
+
File.write(file, '')
|
158
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be false
|
159
|
+
end
|
160
|
+
|
161
|
+
it 'handles file with mixed line endings' do
|
162
|
+
file = File.join(tmpdir, 'mixed.rb')
|
163
|
+
File.write(file, "line1\nline2\r\nline3\n", mode: 'wb')
|
164
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be false
|
165
|
+
end
|
166
|
+
|
167
|
+
it 'returns false for non-existent file' do
|
168
|
+
file = File.join(tmpdir, 'nonexistent.rb')
|
169
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be false
|
170
|
+
end
|
171
|
+
|
172
|
+
it 'handles errors gracefully' do
|
173
|
+
file = File.join(tmpdir, 'test.rb')
|
174
|
+
File.write(file, 'content')
|
175
|
+
|
176
|
+
# Mock File.open to raise an error
|
177
|
+
allow(File).to receive(:open).with(file, 'rb').and_raise(StandardError.new('IO error'))
|
178
|
+
|
179
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be false
|
180
|
+
end
|
181
|
+
|
182
|
+
it 'handles binary files that end with newline' do
|
183
|
+
file = File.join(tmpdir, 'binary.dat')
|
184
|
+
File.write(file, "\x00\x01\x02\x0A", mode: 'wb')
|
185
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be false
|
186
|
+
end
|
187
|
+
|
188
|
+
it 'handles binary files that do not end with newline' do
|
189
|
+
file = File.join(tmpdir, 'binary_no_newline.dat')
|
190
|
+
File.write(file, "\x00\x01\x02\xFF", mode: 'wb')
|
191
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be true
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
context 'line count adjustment with missing trailing newline' do
|
196
|
+
let(:checker) do
|
197
|
+
described_class.new(root: tmpdir, resultset: nil, mode: 'off', timestamp: Time.now)
|
198
|
+
end
|
199
|
+
|
200
|
+
it 'adjusts line count when file has no trailing newline and counts differ by 1' do
|
201
|
+
file = File.join(tmpdir, 'adjust.rb')
|
202
|
+
# Write 3 lines without final newline
|
203
|
+
File.write(file, "line1\nline2\nline3")
|
204
|
+
|
205
|
+
# Coverage has 3 lines, file counts as 3 lines (no newline at end)
|
206
|
+
# but File.foreach will count 3 iterations
|
207
|
+
coverage_lines = [1, 0, 1]
|
208
|
+
details = checker.send(:compute_file_staleness_details, file, coverage_lines)
|
209
|
+
|
210
|
+
expect(details[:len_mismatch]).to be false
|
211
|
+
end
|
212
|
+
|
213
|
+
it 'does not adjust when file has trailing newline' do
|
214
|
+
file = File.join(tmpdir, 'no_adjust.rb')
|
215
|
+
# Write 3 lines with final newline
|
216
|
+
File.write(file, "line1\nline2\nline3\n")
|
217
|
+
|
218
|
+
# Coverage has 3 lines, file also counts as 3 lines (foreach counts by separator)
|
219
|
+
coverage_lines = [1, 0, 1]
|
220
|
+
details = checker.send(:compute_file_staleness_details, file, coverage_lines)
|
221
|
+
|
222
|
+
# No mismatch - both are 3 lines
|
223
|
+
expect(details[:src_len]).to eq(3)
|
224
|
+
expect(details[:cov_len]).to eq(3)
|
225
|
+
expect(details[:len_mismatch]).to be false
|
226
|
+
end
|
227
|
+
|
228
|
+
it 'does not adjust when difference is more than 1' do
|
229
|
+
file = File.join(tmpdir, 'big_diff.rb')
|
230
|
+
File.write(file, "line1\nline2\nline3\nline4\nline5")
|
231
|
+
|
232
|
+
coverage_lines = [1, 0, 1]
|
233
|
+
details = checker.send(:compute_file_staleness_details, file, coverage_lines)
|
234
|
+
|
235
|
+
expect(details[:len_mismatch]).to be true
|
236
|
+
end
|
237
|
+
|
238
|
+
it 'does not adjust when coverage is empty' do
|
239
|
+
file = File.join(tmpdir, 'empty_cov.rb')
|
240
|
+
File.write(file, "line1\nline2")
|
241
|
+
|
242
|
+
coverage_lines = []
|
243
|
+
details = checker.send(:compute_file_staleness_details, file, coverage_lines)
|
244
|
+
|
245
|
+
expect(details[:len_mismatch]).to be false
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
context 'safe_count_lines edge cases' do
|
250
|
+
let(:checker) do
|
251
|
+
described_class.new(root: tmpdir, resultset: nil, mode: 'off', timestamp: Time.now)
|
252
|
+
end
|
253
|
+
|
254
|
+
it 'returns 0 for non-existent file' do
|
255
|
+
file = File.join(tmpdir, 'nonexistent.rb')
|
256
|
+
expect(checker.send(:safe_count_lines, file)).to eq(0)
|
257
|
+
end
|
258
|
+
|
259
|
+
it 'handles errors gracefully' do
|
260
|
+
file = File.join(tmpdir, 'test.rb')
|
261
|
+
File.write(file, "line1\nline2\n")
|
262
|
+
|
263
|
+
# Mock File.foreach to raise an error
|
264
|
+
allow(File).to receive(:foreach).with(file).and_raise(StandardError.new('IO error'))
|
265
|
+
|
266
|
+
expect(checker.send(:safe_count_lines, file)).to eq(0)
|
267
|
+
end
|
268
|
+
|
269
|
+
it 'counts lines correctly for file with final newline' do
|
270
|
+
file = File.join(tmpdir, 'with_newline.rb')
|
271
|
+
File.write(file, "line1\nline2\nline3\n")
|
272
|
+
# File.foreach counts 3 iterations (by line separator)
|
273
|
+
expect(checker.send(:safe_count_lines, file)).to eq(3)
|
274
|
+
end
|
275
|
+
|
276
|
+
it 'counts lines correctly for file without final newline' do
|
277
|
+
file = File.join(tmpdir, 'no_newline.rb')
|
278
|
+
File.write(file, "line1\nline2\nline3")
|
279
|
+
# File.foreach counts 3 iterations
|
280
|
+
expect(checker.send(:safe_count_lines, file)).to eq(3)
|
281
|
+
end
|
282
|
+
|
283
|
+
it 'returns 0 for empty file' do
|
284
|
+
file = File.join(tmpdir, 'empty.rb')
|
285
|
+
File.write(file, '')
|
286
|
+
expect(checker.send(:safe_count_lines, file)).to eq(0)
|
287
|
+
end
|
288
|
+
end
|
289
|
+
|
290
|
+
context 'rel method with path prefix mismatches' do
|
291
|
+
let(:checker) do
|
292
|
+
described_class.new(root: tmpdir, resultset: nil, mode: 'off', timestamp: Time.now)
|
293
|
+
end
|
294
|
+
|
295
|
+
it 'returns relative path for files within project root' do
|
296
|
+
file_inside = File.join(tmpdir, 'lib', 'test.rb')
|
297
|
+
expect(checker.send(:rel, file_inside)).to eq('lib/test.rb')
|
298
|
+
end
|
299
|
+
|
300
|
+
it 'handles ArgumentError when path prefixes differ (absolute vs relative)' do
|
301
|
+
# Test the specific ArgumentError scenario: absolute path vs relative root
|
302
|
+
# This simulates the bug scenario where coverage data has absolute paths
|
303
|
+
# but the root is somehow processed as relative (edge case)
|
304
|
+
checker_with_relative_root = described_class.new(root: '.', resultset: nil, mode: 'off',
|
305
|
+
timestamp: Time.now)
|
306
|
+
|
307
|
+
# Override the @root to simulate the edge case where it's still relative
|
308
|
+
checker_with_relative_root.instance_variable_set(:@root, './subdir')
|
309
|
+
|
310
|
+
file_absolute = '/opt/shared_libs/utils/validation.rb'
|
311
|
+
|
312
|
+
# This should trigger the ArgumentError rescue and return the absolute path
|
313
|
+
expect(checker_with_relative_root.send(:rel, file_absolute))
|
314
|
+
.to eq('/opt/shared_libs/utils/validation.rb')
|
315
|
+
end
|
316
|
+
|
317
|
+
it 'handles relative file paths with absolute root' do
|
318
|
+
file_relative = './lib/test.rb'
|
319
|
+
|
320
|
+
# This should work fine (both are converted to absolute internally)
|
321
|
+
expect { checker.send(:rel, file_relative) }.not_to raise_error
|
322
|
+
end
|
323
|
+
|
324
|
+
it 'works with check_file! when rel encounters ArgumentError' do
|
325
|
+
# Test the specific case where rel() would crash with ArgumentError
|
326
|
+
# Instead of testing the full check_file! flow, just test that rel() works
|
327
|
+
|
328
|
+
checker_with_edge_case = described_class.new(root: '.', resultset: nil, mode: 'off',
|
329
|
+
timestamp: Time.now)
|
330
|
+
checker_with_edge_case.instance_variable_set(:@root, './subdir')
|
331
|
+
|
332
|
+
file_outside = '/opt/company_gem/lib/core.rb'
|
333
|
+
|
334
|
+
# This should trigger the ArgumentError and return the absolute path
|
335
|
+
# instead of crashing with ArgumentError
|
336
|
+
result = checker_with_edge_case.send(:rel, file_outside)
|
337
|
+
expect(result).to eq('/opt/company_gem/lib/core.rb')
|
338
|
+
|
339
|
+
# Verify it doesn't raise ArgumentError
|
340
|
+
expect { checker_with_edge_case.send(:rel, file_outside) }.not_to raise_error
|
341
|
+
end
|
342
|
+
|
343
|
+
it 'handles files outside project root gracefully (returns relative path with ..)' do
|
344
|
+
# Test that normal "outside but compatible" paths still work
|
345
|
+
file_outside = '/tmp/external_file.rb'
|
346
|
+
|
347
|
+
# This should return a relative path with .. (not trigger ArgumentError)
|
348
|
+
result = checker.send(:rel, file_outside)
|
349
|
+
expect(result).to include('..') # Should contain relative navigation
|
350
|
+
expect(result).not_to start_with('/') # Should be relative, not absolute
|
351
|
+
end
|
352
|
+
|
353
|
+
it 'allows project-level staleness checks to handle coverage outside root' do
|
354
|
+
future_time = Time.at(Time.now.to_i + 3600)
|
355
|
+
checker_with_relative_root = described_class.new(root: '.', resultset: nil, mode: 'error',
|
356
|
+
timestamp: future_time)
|
357
|
+
checker_with_relative_root.instance_variable_set(:@root, './subdir')
|
358
|
+
|
359
|
+
external_dir = Dir.mktmpdir('scmcp-outside')
|
360
|
+
|
361
|
+
begin
|
362
|
+
external_file = File.join(external_dir, 'shared.rb')
|
363
|
+
File.write(external_file, "puts 'hi'\n")
|
364
|
+
|
365
|
+
coverage_map = { external_file => [1] }
|
366
|
+
|
367
|
+
expect { checker_with_relative_root.check_project!(coverage_map) }.not_to raise_error
|
368
|
+
ensure
|
369
|
+
FileUtils.remove_entry(external_dir) if external_dir && File.directory?(external_dir)
|
370
|
+
end
|
371
|
+
end
|
372
|
+
end
|
373
|
+
end
|
data/spec/staleness_more_spec.rb
CHANGED
@@ -3,37 +3,40 @@
|
|
3
3
|
require 'spec_helper'
|
4
4
|
|
5
5
|
RSpec.describe 'Additional staleness cases' do
|
6
|
-
let(:root) { (
|
6
|
+
let(:root) { (FIXTURES_DIR / 'project1').to_s }
|
7
7
|
|
8
8
|
describe SimpleCovMcp::CoverageModel do
|
9
9
|
it 'raises file-level stale when source and coverage lengths differ' do
|
10
|
-
# Ensure time is not the triggering factor
|
11
|
-
|
10
|
+
# Ensure time is not the triggering factor - use current timestamp
|
11
|
+
mock_resultset_with_timestamp(root, Time.now.to_i, coverage: {
|
12
|
+
File.join(root, 'lib', 'bar.rb') => { 'lines' => [1, 1] } # 2 entries vs 3 lines in source
|
13
|
+
})
|
12
14
|
model = SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage', staleness: 'error')
|
13
|
-
# bar.rb has
|
14
|
-
expect
|
15
|
+
# bar.rb has 2 coverage entries but 3 source lines in fixtures
|
16
|
+
expect do
|
15
17
|
model.summary_for('lib/bar.rb')
|
16
|
-
|
18
|
+
end.to raise_error(SimpleCovMcp::CoverageDataStaleError, /stale/i)
|
17
19
|
end
|
18
20
|
end
|
19
21
|
|
20
22
|
describe SimpleCovMcp::StalenessChecker do
|
21
23
|
it 'flags deleted files present only in coverage' do
|
22
|
-
checker = described_class.new(root: root, resultset: 'coverage', mode: 'error'
|
24
|
+
checker = described_class.new(root: root, resultset: 'coverage', mode: 'error',
|
25
|
+
timestamp: Time.now.to_i)
|
23
26
|
coverage_map = {
|
24
27
|
File.join(root, 'lib', 'does_not_exist_anymore.rb') => { 'lines' => [1] }
|
25
28
|
}
|
26
|
-
expect
|
29
|
+
expect do
|
27
30
|
checker.check_project!(coverage_map)
|
28
|
-
|
31
|
+
end.to raise_error(SimpleCovMcp::CoverageDataProjectStaleError)
|
29
32
|
end
|
30
33
|
|
31
34
|
it 'does not raise for empty tracked_globs when nothing else is stale' do
|
32
|
-
|
33
|
-
|
34
|
-
expect
|
35
|
+
checker = described_class.new(root: root, resultset: 'coverage', mode: 'error',
|
36
|
+
tracked_globs: [], timestamp: Time.now.to_i)
|
37
|
+
expect do
|
35
38
|
checker.check_project!({})
|
36
|
-
|
39
|
+
end.not_to raise_error
|
37
40
|
end
|
38
41
|
end
|
39
42
|
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'open3'
|
4
|
+
require 'timeout'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
module Spec
|
8
|
+
module Support
|
9
|
+
module McpRunner
|
10
|
+
# Thin wrapper around `Open3.popen3` that standardizes how the integration
|
11
|
+
# specs talk to the `simplecov-mcp` executable. It accepts either a single
|
12
|
+
# JSON-RPC request hash, a sequence of requests, or raw string input,
|
13
|
+
# writes them to the subprocess stdin (ensuring a trailing newline), then
|
14
|
+
# collects stdout, stderr, and the exit status with a timeout. The helper
|
15
|
+
# always returns a hash containing those streams plus the `Process::Status`
|
16
|
+
# so callers can make assertions without duplicating the boilerplate.
|
17
|
+
|
18
|
+
module_function
|
19
|
+
|
20
|
+
def call(requests: nil, input: nil, env: {}, lib_path:, exe_path:, timeout: 5,
|
21
|
+
close_stdin: true)
|
22
|
+
payload = build_payload(requests, input)
|
23
|
+
|
24
|
+
stdout_str = ''
|
25
|
+
stderr_str = ''
|
26
|
+
status = nil
|
27
|
+
|
28
|
+
Open3.popen3(env, 'ruby', '-I', lib_path, exe_path) do |stdin, stdout, stderr, wait_thr|
|
29
|
+
unless payload.nil?
|
30
|
+
stdin.write(payload)
|
31
|
+
stdin.write("\n") if !payload.empty? && !payload.end_with?("\n")
|
32
|
+
end
|
33
|
+
stdin.close if close_stdin
|
34
|
+
|
35
|
+
Timeout.timeout(timeout) do
|
36
|
+
stdout_str = stdout.read
|
37
|
+
stderr_str = stderr.read
|
38
|
+
status = wait_thr.value
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
{ stdout: stdout_str, stderr: stderr_str, status: status }
|
43
|
+
rescue Timeout::Error
|
44
|
+
raise "MCP server timed out after #{timeout} seconds"
|
45
|
+
end
|
46
|
+
|
47
|
+
def call_json(request_hash, **kwargs)
|
48
|
+
call(requests: request_hash, **kwargs)
|
49
|
+
end
|
50
|
+
|
51
|
+
def call_json_stream(request_hashes, **kwargs)
|
52
|
+
call(requests: Array(request_hashes), **kwargs)
|
53
|
+
end
|
54
|
+
|
55
|
+
def build_payload(requests, input)
|
56
|
+
return input unless requests
|
57
|
+
|
58
|
+
normalized = requests.is_a?(Array) ? requests : [requests]
|
59
|
+
normalized.map { |req| req.is_a?(String) ? req : JSON.generate(req) }.join("\n")
|
60
|
+
end
|
61
|
+
private_class_method :build_payload
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|