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.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +173 -356
  4. data/docs/ADVANCED_USAGE.md +967 -0
  5. data/docs/ARCHITECTURE.md +79 -0
  6. data/docs/BRANCH_ONLY_COVERAGE.md +81 -0
  7. data/docs/CLI_USAGE.md +637 -0
  8. data/docs/DEVELOPMENT.md +82 -0
  9. data/docs/ERROR_HANDLING.md +93 -0
  10. data/docs/EXAMPLES.md +430 -0
  11. data/docs/INSTALLATION.md +352 -0
  12. data/docs/LIBRARY_API.md +635 -0
  13. data/docs/MCP_INTEGRATION.md +488 -0
  14. data/docs/TROUBLESHOOTING.md +276 -0
  15. data/docs/arch-decisions/001-x-arch-decision.md +93 -0
  16. data/docs/arch-decisions/002-x-arch-decision.md +157 -0
  17. data/docs/arch-decisions/003-x-arch-decision.md +163 -0
  18. data/docs/arch-decisions/004-x-arch-decision.md +199 -0
  19. data/docs/arch-decisions/005-x-arch-decision.md +187 -0
  20. data/docs/arch-decisions/README.md +60 -0
  21. data/docs/presentations/simplecov-mcp-presentation.md +249 -0
  22. data/exe/simplecov-mcp +4 -4
  23. data/lib/simplecov_mcp/app_context.rb +26 -0
  24. data/lib/simplecov_mcp/base_tool.rb +74 -0
  25. data/lib/simplecov_mcp/cli.rb +234 -0
  26. data/lib/simplecov_mcp/cli_config.rb +56 -0
  27. data/lib/simplecov_mcp/commands/base_command.rb +78 -0
  28. data/lib/simplecov_mcp/commands/command_factory.rb +39 -0
  29. data/lib/simplecov_mcp/commands/detailed_command.rb +24 -0
  30. data/lib/simplecov_mcp/commands/list_command.rb +13 -0
  31. data/lib/simplecov_mcp/commands/raw_command.rb +22 -0
  32. data/lib/simplecov_mcp/commands/summary_command.rb +24 -0
  33. data/lib/simplecov_mcp/commands/uncovered_command.rb +26 -0
  34. data/lib/simplecov_mcp/commands/version_command.rb +18 -0
  35. data/lib/simplecov_mcp/constants.rb +22 -0
  36. data/lib/simplecov_mcp/error_handler.rb +124 -0
  37. data/lib/simplecov_mcp/error_handler_factory.rb +31 -0
  38. data/lib/simplecov_mcp/errors.rb +179 -0
  39. data/lib/simplecov_mcp/formatters/source_formatter.rb +148 -0
  40. data/lib/simplecov_mcp/mcp_server.rb +40 -0
  41. data/lib/simplecov_mcp/mode_detector.rb +55 -0
  42. data/lib/simplecov_mcp/model.rb +300 -0
  43. data/lib/simplecov_mcp/option_normalizers.rb +92 -0
  44. data/lib/simplecov_mcp/option_parser_builder.rb +134 -0
  45. data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +50 -0
  46. data/lib/simplecov_mcp/option_parsers/error_helper.rb +109 -0
  47. data/lib/simplecov_mcp/path_relativizer.rb +61 -0
  48. data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +44 -0
  49. data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +16 -0
  50. data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +16 -0
  51. data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +16 -0
  52. data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +16 -0
  53. data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +52 -0
  54. data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +126 -0
  55. data/lib/simplecov_mcp/resolvers/resolver_factory.rb +28 -0
  56. data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +78 -0
  57. data/lib/simplecov_mcp/resultset_loader.rb +136 -0
  58. data/lib/simplecov_mcp/staleness_checker.rb +243 -0
  59. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/all_files_coverage_tool.rb +31 -13
  60. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_detailed_tool.rb +7 -7
  61. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_raw_tool.rb +7 -7
  62. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_summary_tool.rb +7 -7
  63. data/lib/simplecov_mcp/tools/coverage_table_tool.rb +90 -0
  64. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/help_tool.rb +13 -4
  65. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/uncovered_lines_tool.rb +7 -7
  66. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/version_tool.rb +11 -3
  67. data/lib/simplecov_mcp/util.rb +82 -0
  68. data/lib/{simple_cov_mcp → simplecov_mcp}/version.rb +1 -1
  69. data/lib/simplecov_mcp.rb +144 -2
  70. data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
  71. data/spec/TIMESTAMPS.md +48 -0
  72. data/spec/all_files_coverage_tool_spec.rb +29 -25
  73. data/spec/base_tool_spec.rb +11 -10
  74. data/spec/cli/show_default_report_spec.rb +33 -0
  75. data/spec/cli_config_spec.rb +137 -0
  76. data/spec/cli_enumerated_options_spec.rb +68 -0
  77. data/spec/cli_error_spec.rb +105 -47
  78. data/spec/cli_source_spec.rb +82 -23
  79. data/spec/cli_spec.rb +140 -5
  80. data/spec/cli_success_predicate_spec.rb +141 -0
  81. data/spec/cli_table_spec.rb +1 -1
  82. data/spec/cli_usage_spec.rb +10 -26
  83. data/spec/commands/base_command_spec.rb +187 -0
  84. data/spec/commands/command_factory_spec.rb +72 -0
  85. data/spec/commands/detailed_command_spec.rb +48 -0
  86. data/spec/commands/raw_command_spec.rb +46 -0
  87. data/spec/commands/summary_command_spec.rb +47 -0
  88. data/spec/commands/uncovered_command_spec.rb +49 -0
  89. data/spec/constants_spec.rb +61 -0
  90. data/spec/coverage_table_tool_spec.rb +17 -33
  91. data/spec/error_handler_spec.rb +22 -13
  92. data/spec/error_mode_spec.rb +143 -0
  93. data/spec/errors_edge_cases_spec.rb +239 -0
  94. data/spec/errors_stale_spec.rb +2 -2
  95. data/spec/file_based_mcp_tools_spec.rb +99 -0
  96. data/spec/fixtures/project1/lib/bar.rb +0 -1
  97. data/spec/fixtures/project1/lib/foo.rb +0 -1
  98. data/spec/help_tool_spec.rb +11 -17
  99. data/spec/integration_spec.rb +845 -0
  100. data/spec/logging_fallback_spec.rb +128 -0
  101. data/spec/mcp_logging_spec.rb +44 -0
  102. data/spec/mcp_server_integration_spec.rb +23 -0
  103. data/spec/mcp_server_spec.rb +15 -4
  104. data/spec/mode_detector_spec.rb +148 -0
  105. data/spec/model_error_handling_spec.rb +210 -0
  106. data/spec/model_staleness_spec.rb +40 -10
  107. data/spec/option_normalizers_spec.rb +204 -0
  108. data/spec/option_parsers/env_options_parser_spec.rb +233 -0
  109. data/spec/option_parsers/error_helper_spec.rb +222 -0
  110. data/spec/path_relativizer_spec.rb +83 -0
  111. data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
  112. data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
  113. data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
  114. data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
  115. data/spec/presenters/project_coverage_presenter_spec.rb +86 -0
  116. data/spec/resolvers/coverage_line_resolver_spec.rb +57 -0
  117. data/spec/resolvers/resolver_factory_spec.rb +61 -0
  118. data/spec/resolvers/resultset_path_resolver_spec.rb +55 -0
  119. data/spec/resultset_loader_spec.rb +167 -0
  120. data/spec/shared_examples/README.md +115 -0
  121. data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
  122. data/spec/shared_examples/file_based_mcp_tools.rb +174 -0
  123. data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
  124. data/spec/simple_cov_mcp_module_spec.rb +16 -0
  125. data/spec/simplecov_mcp_model_spec.rb +340 -9
  126. data/spec/simplecov_mcp_opts_spec.rb +182 -0
  127. data/spec/spec_helper.rb +147 -4
  128. data/spec/staleness_checker_spec.rb +373 -0
  129. data/spec/staleness_more_spec.rb +16 -13
  130. data/spec/support/mcp_runner.rb +64 -0
  131. data/spec/tools_error_handling_spec.rb +144 -0
  132. data/spec/util_spec.rb +109 -34
  133. data/spec/version_spec.rb +117 -9
  134. data/spec/version_tool_spec.rb +131 -10
  135. metadata +120 -63
  136. data/lib/simple_cov/mcp.rb +0 -9
  137. data/lib/simple_cov_mcp/base_tool.rb +0 -70
  138. data/lib/simple_cov_mcp/cli.rb +0 -390
  139. data/lib/simple_cov_mcp/error_handler.rb +0 -131
  140. data/lib/simple_cov_mcp/error_handler_factory.rb +0 -38
  141. data/lib/simple_cov_mcp/errors.rb +0 -176
  142. data/lib/simple_cov_mcp/mcp_server.rb +0 -30
  143. data/lib/simple_cov_mcp/model.rb +0 -104
  144. data/lib/simple_cov_mcp/staleness_checker.rb +0 -125
  145. data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +0 -61
  146. data/lib/simple_cov_mcp/util.rb +0 -122
  147. data/lib/simple_cov_mcp.rb +0 -102
  148. data/spec/coverage_detailed_tool_spec.rb +0 -36
  149. data/spec/coverage_raw_tool_spec.rb +0 -32
  150. data/spec/coverage_summary_tool_spec.rb +0 -39
  151. data/spec/legacy_shim_spec.rb +0 -13
  152. 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 'simple_cov_mcp'
20
+ require 'simplecov_mcp'
22
21
 
23
- FIXTURES = Pathname.new(File.expand_path('fixtures', __dir__))
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 = :random
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
@@ -3,37 +3,40 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  RSpec.describe 'Additional staleness cases' do
6
- let(:root) { (FIXTURES / 'project1').to_s }
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
- allow(SimpleCovMcp::CovUtil).to receive(:latest_timestamp).and_return(Time.now.to_i)
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 3 coverage entries but 4 source lines in fixtures
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
- }.to raise_error(SimpleCovMcp::CoverageDataStaleError, /stale/i)
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
- }.to raise_error(SimpleCovMcp::CoverageDataProjectStaleError)
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
- allow(SimpleCovMcp::CovUtil).to receive(:latest_timestamp).and_return(Time.now.to_i)
33
- checker = described_class.new(root: root, resultset: 'coverage', mode: 'error', tracked_globs: [])
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
- }.not_to raise_error
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