cov-loupe 3.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 (171) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +329 -0
  4. data/docs/dev/ARCHITECTURE.md +80 -0
  5. data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
  6. data/docs/dev/DEVELOPMENT.md +83 -0
  7. data/docs/dev/README.md +10 -0
  8. data/docs/dev/RELEASING.md +146 -0
  9. data/docs/dev/arch-decisions/001-x-arch-decision.md +95 -0
  10. data/docs/dev/arch-decisions/002-x-arch-decision.md +159 -0
  11. data/docs/dev/arch-decisions/003-x-arch-decision.md +165 -0
  12. data/docs/dev/arch-decisions/004-x-arch-decision.md +203 -0
  13. data/docs/dev/arch-decisions/005-x-arch-decision.md +189 -0
  14. data/docs/dev/arch-decisions/README.md +60 -0
  15. data/docs/dev/presentations/cov-loupe-presentation.md +255 -0
  16. data/docs/fixtures/demo_project/README.md +9 -0
  17. data/docs/user/ADVANCED_USAGE.md +777 -0
  18. data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
  19. data/docs/user/CLI_USAGE.md +750 -0
  20. data/docs/user/ERROR_HANDLING.md +93 -0
  21. data/docs/user/EXAMPLES.md +588 -0
  22. data/docs/user/INSTALLATION.md +130 -0
  23. data/docs/user/LIBRARY_API.md +693 -0
  24. data/docs/user/MCP_INTEGRATION.md +490 -0
  25. data/docs/user/README.md +14 -0
  26. data/docs/user/TROUBLESHOOTING.md +197 -0
  27. data/docs/user/V2-BREAKING-CHANGES.md +472 -0
  28. data/exe/cov-loupe +23 -0
  29. data/lib/cov_loupe/app_config.rb +56 -0
  30. data/lib/cov_loupe/app_context.rb +26 -0
  31. data/lib/cov_loupe/base_tool.rb +102 -0
  32. data/lib/cov_loupe/cli.rb +178 -0
  33. data/lib/cov_loupe/commands/base_command.rb +67 -0
  34. data/lib/cov_loupe/commands/command_factory.rb +45 -0
  35. data/lib/cov_loupe/commands/detailed_command.rb +38 -0
  36. data/lib/cov_loupe/commands/list_command.rb +13 -0
  37. data/lib/cov_loupe/commands/raw_command.rb +38 -0
  38. data/lib/cov_loupe/commands/summary_command.rb +41 -0
  39. data/lib/cov_loupe/commands/totals_command.rb +53 -0
  40. data/lib/cov_loupe/commands/uncovered_command.rb +45 -0
  41. data/lib/cov_loupe/commands/validate_command.rb +60 -0
  42. data/lib/cov_loupe/commands/version_command.rb +33 -0
  43. data/lib/cov_loupe/config_parser.rb +32 -0
  44. data/lib/cov_loupe/constants.rb +22 -0
  45. data/lib/cov_loupe/coverage_reporter.rb +31 -0
  46. data/lib/cov_loupe/error_handler.rb +165 -0
  47. data/lib/cov_loupe/error_handler_factory.rb +31 -0
  48. data/lib/cov_loupe/errors.rb +191 -0
  49. data/lib/cov_loupe/formatters/source_formatter.rb +152 -0
  50. data/lib/cov_loupe/formatters.rb +51 -0
  51. data/lib/cov_loupe/mcp_server.rb +42 -0
  52. data/lib/cov_loupe/mode_detector.rb +56 -0
  53. data/lib/cov_loupe/model.rb +339 -0
  54. data/lib/cov_loupe/option_normalizers.rb +113 -0
  55. data/lib/cov_loupe/option_parser_builder.rb +147 -0
  56. data/lib/cov_loupe/option_parsers/env_options_parser.rb +48 -0
  57. data/lib/cov_loupe/option_parsers/error_helper.rb +110 -0
  58. data/lib/cov_loupe/path_relativizer.rb +64 -0
  59. data/lib/cov_loupe/predicate_evaluator.rb +72 -0
  60. data/lib/cov_loupe/presenters/base_coverage_presenter.rb +42 -0
  61. data/lib/cov_loupe/presenters/coverage_detailed_presenter.rb +14 -0
  62. data/lib/cov_loupe/presenters/coverage_raw_presenter.rb +14 -0
  63. data/lib/cov_loupe/presenters/coverage_summary_presenter.rb +14 -0
  64. data/lib/cov_loupe/presenters/coverage_uncovered_presenter.rb +14 -0
  65. data/lib/cov_loupe/presenters/project_coverage_presenter.rb +50 -0
  66. data/lib/cov_loupe/presenters/project_totals_presenter.rb +27 -0
  67. data/lib/cov_loupe/resolvers/coverage_line_resolver.rb +122 -0
  68. data/lib/cov_loupe/resolvers/resolver_factory.rb +28 -0
  69. data/lib/cov_loupe/resolvers/resultset_path_resolver.rb +76 -0
  70. data/lib/cov_loupe/resultset_loader.rb +131 -0
  71. data/lib/cov_loupe/staleness_checker.rb +247 -0
  72. data/lib/cov_loupe/table_formatter.rb +64 -0
  73. data/lib/cov_loupe/tools/all_files_coverage_tool.rb +51 -0
  74. data/lib/cov_loupe/tools/coverage_detailed_tool.rb +35 -0
  75. data/lib/cov_loupe/tools/coverage_raw_tool.rb +34 -0
  76. data/lib/cov_loupe/tools/coverage_summary_tool.rb +34 -0
  77. data/lib/cov_loupe/tools/coverage_table_tool.rb +50 -0
  78. data/lib/cov_loupe/tools/coverage_totals_tool.rb +44 -0
  79. data/lib/cov_loupe/tools/help_tool.rb +115 -0
  80. data/lib/cov_loupe/tools/uncovered_lines_tool.rb +34 -0
  81. data/lib/cov_loupe/tools/validate_tool.rb +72 -0
  82. data/lib/cov_loupe/tools/version_tool.rb +32 -0
  83. data/lib/cov_loupe/util.rb +88 -0
  84. data/lib/cov_loupe/version.rb +5 -0
  85. data/lib/cov_loupe.rb +140 -0
  86. data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
  87. data/spec/TIMESTAMPS.md +48 -0
  88. data/spec/all_files_coverage_tool_spec.rb +53 -0
  89. data/spec/app_config_spec.rb +142 -0
  90. data/spec/base_tool_spec.rb +62 -0
  91. data/spec/cli/show_default_report_spec.rb +33 -0
  92. data/spec/cli_enumerated_options_spec.rb +90 -0
  93. data/spec/cli_error_spec.rb +184 -0
  94. data/spec/cli_format_spec.rb +123 -0
  95. data/spec/cli_json_options_spec.rb +50 -0
  96. data/spec/cli_source_spec.rb +44 -0
  97. data/spec/cli_spec.rb +192 -0
  98. data/spec/cli_table_spec.rb +28 -0
  99. data/spec/cli_usage_spec.rb +42 -0
  100. data/spec/commands/base_command_spec.rb +107 -0
  101. data/spec/commands/command_factory_spec.rb +76 -0
  102. data/spec/commands/detailed_command_spec.rb +34 -0
  103. data/spec/commands/list_command_spec.rb +28 -0
  104. data/spec/commands/raw_command_spec.rb +69 -0
  105. data/spec/commands/summary_command_spec.rb +34 -0
  106. data/spec/commands/totals_command_spec.rb +34 -0
  107. data/spec/commands/uncovered_command_spec.rb +55 -0
  108. data/spec/commands/validate_command_spec.rb +213 -0
  109. data/spec/commands/version_command_spec.rb +38 -0
  110. data/spec/constants_spec.rb +61 -0
  111. data/spec/cov_loupe/formatters/source_formatter_spec.rb +267 -0
  112. data/spec/cov_loupe/formatters_spec.rb +76 -0
  113. data/spec/cov_loupe/presenters/base_coverage_presenter_spec.rb +79 -0
  114. data/spec/cov_loupe_model_spec.rb +454 -0
  115. data/spec/cov_loupe_module_spec.rb +37 -0
  116. data/spec/cov_loupe_opts_spec.rb +185 -0
  117. data/spec/coverage_reporter_spec.rb +102 -0
  118. data/spec/coverage_table_tool_spec.rb +59 -0
  119. data/spec/coverage_totals_tool_spec.rb +37 -0
  120. data/spec/error_handler_spec.rb +197 -0
  121. data/spec/error_mode_spec.rb +139 -0
  122. data/spec/errors_edge_cases_spec.rb +312 -0
  123. data/spec/errors_stale_spec.rb +83 -0
  124. data/spec/file_based_mcp_tools_spec.rb +99 -0
  125. data/spec/fixtures/project1/lib/bar.rb +5 -0
  126. data/spec/fixtures/project1/lib/foo.rb +6 -0
  127. data/spec/help_tool_spec.rb +26 -0
  128. data/spec/integration_spec.rb +789 -0
  129. data/spec/logging_fallback_spec.rb +128 -0
  130. data/spec/mcp_logging_spec.rb +44 -0
  131. data/spec/mcp_server_integration_spec.rb +23 -0
  132. data/spec/mcp_server_spec.rb +106 -0
  133. data/spec/mode_detector_spec.rb +153 -0
  134. data/spec/model_error_handling_spec.rb +269 -0
  135. data/spec/model_staleness_spec.rb +79 -0
  136. data/spec/option_normalizers_spec.rb +203 -0
  137. data/spec/option_parsers/env_options_parser_spec.rb +221 -0
  138. data/spec/option_parsers/error_helper_spec.rb +222 -0
  139. data/spec/path_relativizer_spec.rb +98 -0
  140. data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
  141. data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
  142. data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
  143. data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
  144. data/spec/presenters/project_coverage_presenter_spec.rb +87 -0
  145. data/spec/presenters/project_totals_presenter_spec.rb +144 -0
  146. data/spec/resolvers/coverage_line_resolver_spec.rb +282 -0
  147. data/spec/resolvers/resolver_factory_spec.rb +61 -0
  148. data/spec/resolvers/resultset_path_resolver_spec.rb +60 -0
  149. data/spec/resultset_loader_spec.rb +167 -0
  150. data/spec/shared_examples/README.md +115 -0
  151. data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
  152. data/spec/shared_examples/file_based_mcp_tools.rb +179 -0
  153. data/spec/shared_examples/formatted_command_examples.rb +64 -0
  154. data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
  155. data/spec/spec_helper.rb +127 -0
  156. data/spec/staleness_checker_spec.rb +374 -0
  157. data/spec/staleness_more_spec.rb +42 -0
  158. data/spec/support/cli_helpers.rb +22 -0
  159. data/spec/support/control_flow_helpers.rb +20 -0
  160. data/spec/support/fake_mcp.rb +40 -0
  161. data/spec/support/io_helpers.rb +29 -0
  162. data/spec/support/mcp_helpers.rb +35 -0
  163. data/spec/support/mcp_runner.rb +66 -0
  164. data/spec/support/mocking_helpers.rb +30 -0
  165. data/spec/table_format_spec.rb +70 -0
  166. data/spec/tools/validate_tool_spec.rb +132 -0
  167. data/spec/tools_error_handling_spec.rb +130 -0
  168. data/spec/util_spec.rb +154 -0
  169. data/spec/version_spec.rb +123 -0
  170. data/spec/version_tool_spec.rb +141 -0
  171. metadata +290 -0
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Enable SimpleCov for this project (coverage output in ./coverage)
4
+ begin
5
+ require 'simplecov'
6
+ SimpleCov.start do
7
+ enable_coverage :branch if SimpleCov.respond_to?(:enable_coverage)
8
+ add_filter(/^\/spec\//)
9
+ track_files 'lib/**/*.rb'
10
+ end
11
+
12
+ # Report lowest coverage files at the end of the test run
13
+ SimpleCov.at_exit do
14
+ SimpleCov.result.format!
15
+ require 'cov_loupe'
16
+ report = CovLoupe::CoverageReporter.report(threshold: 80, count: 5)
17
+ puts report if report
18
+ end
19
+ rescue LoadError
20
+ warn 'SimpleCov not available; skipping coverage'
21
+ end
22
+
23
+
24
+ require 'rspec'
25
+ require 'pathname'
26
+ require 'json'
27
+
28
+ require 'cov_loupe'
29
+
30
+ FIXTURES_DIR = Pathname.new(File.expand_path('fixtures', __dir__))
31
+
32
+ # Test timestamp constants for consistent and documented test data
33
+ # Main fixture coverage timestamp: 1720000000 = 2024-07-03 16:26:40 UTC
34
+ # This represents when the coverage data in spec/fixtures/project1/coverage/.resultset.json was "generated"
35
+ FIXTURE_COVERAGE_TIMESTAMP = 1_720_000_000
36
+
37
+ # Very old timestamp: 0 = 1970-01-01 00:00:00 UTC (Unix epoch)
38
+ # Used in tests to simulate stale coverage (much older than any real file)
39
+ VERY_OLD_TIMESTAMP = 0
40
+
41
+ # Test timestamps for stale error formatting tests
42
+ # 1000 = 1970-01-01 00:16:40 UTC (16 minutes and 40 seconds after epoch)
43
+ TEST_FILE_TIMESTAMP = 1_000
44
+
45
+ # Regex pattern for matching ISO 8601 timestamps with brackets in log output
46
+ # Used to verify log timestamps in tests
47
+ TIMESTAMP_REGEX = /\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}\]/
48
+
49
+ # Helper method to mock resultset file reading with fake coverage data
50
+ # @param root [String] The test root directory
51
+ # @param timestamp [Integer] The timestamp to use in the fake resultset
52
+ # @param coverage [Hash] Optional custom coverage data (default: basic foo.rb and bar.rb)
53
+ def mock_resultset_with_timestamp(root, timestamp, coverage: nil)
54
+ mock_resultset_with_metadata(root, { 'timestamp' => timestamp }, coverage: coverage)
55
+ end
56
+
57
+ def mock_resultset_with_created_at(root, created_at, coverage: nil)
58
+ mock_resultset_with_metadata(root, { 'created_at' => created_at }, coverage: coverage)
59
+ end
60
+
61
+ def mock_resultset_with_metadata(root, metadata, coverage: nil)
62
+ abs_root = File.absolute_path(root)
63
+ default_coverage = {
64
+ File.join(root, 'lib', 'foo.rb') => { 'lines' => [1, 0, nil, 2] },
65
+ File.join(root, 'lib', 'bar.rb') => { 'lines' => [0, 0, 1] }
66
+ }
67
+
68
+ fake_resultset_hash = {
69
+ 'RSpec' => {
70
+ 'coverage' => coverage || default_coverage
71
+ }.merge(metadata)
72
+ }
73
+
74
+ allow(JSON).to receive(:load_file).and_call_original # Allow real JSON.load_file for other calls
75
+
76
+ allow(JSON).to receive(:load_file).with(end_with('.resultset.json'))
77
+ .and_return(fake_resultset_hash)
78
+ allow(CovLoupe::CovUtil).to receive(:find_resultset)
79
+ .and_wrap_original do |method, search_root, resultset: nil|
80
+ if File.absolute_path(search_root) == abs_root && (resultset.nil? || resultset.to_s.empty?)
81
+ File.join(abs_root, 'coverage', '.resultset.json')
82
+ else
83
+ method.call(search_root, resultset: resultset)
84
+ end
85
+ end
86
+ end
87
+
88
+ # Automatically require all files in spec/support and spec/shared_examples
89
+ Dir[File.join(__dir__, 'support', '**', '*.rb')].each { |f| require f }
90
+ Dir[File.join(__dir__, 'shared_examples', '**', '*.rb')].each { |f| require f }
91
+
92
+ RSpec.configure do |config|
93
+ config.example_status_persistence_file_path = '.rspec_status'
94
+ config.disable_monkey_patching!
95
+ config.order = :defined
96
+ Kernel.srand config.seed
97
+
98
+ # Suppress logging during tests by redirecting to /dev/null
99
+ # This is cheap and doesn't break tests that verify logging behavior
100
+ CovLoupe.default_log_file = 'stderr'
101
+ CovLoupe.active_log_file = 'stderr'
102
+
103
+ # Reset log file after each test to ensure tests that change it don't pollute others
104
+ config.after do
105
+ CovLoupe.active_log_file = File::NULL
106
+ end
107
+
108
+ config.include TestIOHelpers
109
+ config.include CLITestHelpers
110
+ config.include MCPToolTestHelpers
111
+ config.include MockingHelpers
112
+ config.include ControlFlowHelpers
113
+ end
114
+
115
+ # Custom matchers
116
+ RSpec::Matchers.define :show_source_table_or_fallback do
117
+ match do |output|
118
+ has_table_header = output.match?(/(^|\n)\s*Line\s*\|\s+Source/)
119
+ has_fallback = output.include?('[source not available]')
120
+ has_table_header || has_fallback
121
+ end
122
+
123
+ failure_message do |_output|
124
+ "expected output to include a source table header (e.g., 'Line | Source') " \
125
+ "or the fallback '[source not available]'"
126
+ end
127
+ end
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'fileutils'
5
+
6
+ RSpec.describe CovLoupe::StalenessChecker do
7
+ let(:tmpdir) { Dir.mktmpdir('scmcp-stale') }
8
+
9
+ after { FileUtils.remove_entry(tmpdir) if tmpdir && File.directory?(tmpdir) }
10
+
11
+ def write_file(path, lines)
12
+ FileUtils.mkdir_p(File.dirname(path))
13
+ File.open(path, 'w') { |f| lines.each { |l| f.puts(l) } }
14
+ end
15
+
16
+ shared_examples 'a staleness check' do |
17
+ description:,
18
+ file_lines:,
19
+ coverage_lines:,
20
+ timestamp:,
21
+ expected_details:,
22
+ expected_stale_char:,
23
+ expected_error:
24
+ |
25
+ it description do
26
+ file = File.join(tmpdir, 'lib', 'test.rb')
27
+ write_file(file, file_lines) if file_lines
28
+
29
+ ts = if timestamp == :past
30
+ now = Time.now
31
+ past = Time.at(now.to_i - 3600)
32
+ File.utime(past, past, file)
33
+ now
34
+ else
35
+ timestamp
36
+ end
37
+
38
+ checker = described_class.new(root: tmpdir, resultset: nil, mode: 'error',
39
+ tracked_globs: nil, timestamp: ts)
40
+
41
+ details = checker.send(:compute_file_staleness_details, file, coverage_lines)
42
+
43
+ expected_details.each do |key, value|
44
+ if value == :any
45
+ expect(details).to have_key(key)
46
+ else
47
+ expect(details[key]).to eq(value)
48
+ end
49
+ end
50
+
51
+ expect(checker.stale_for_file?(file, coverage_lines)).to eq(expected_stale_char)
52
+
53
+ if expected_error
54
+ expect { checker.check_file!(file, coverage_lines) }.to raise_error(expected_error)
55
+ else
56
+ expect { checker.check_file!(file, coverage_lines) }.not_to raise_error
57
+ end
58
+ end
59
+ end
60
+
61
+ context 'when computing file staleness details' do
62
+ it_behaves_like 'a staleness check',
63
+ description: 'detects newer file vs coverage timestamp',
64
+ file_lines: ['a', 'b'],
65
+ coverage_lines: [1, 1],
66
+ timestamp: Time.at(Time.now.to_i - 3600),
67
+ expected_details: {
68
+ exists: true,
69
+ cov_len: 2,
70
+ src_len: 2,
71
+ newer: true,
72
+ len_mismatch: false,
73
+ file_mtime: :any,
74
+ coverage_timestamp: :any
75
+ },
76
+ expected_stale_char: 'T',
77
+ expected_error: CovLoupe::CoverageDataStaleError
78
+
79
+ it_behaves_like 'a staleness check',
80
+ description: 'detects length mismatch between source and coverage',
81
+ file_lines: ['a', 'b', 'c', 'd'],
82
+ coverage_lines: [1, 1],
83
+ timestamp: Time.now,
84
+ expected_details: {
85
+ exists: true,
86
+ cov_len: 2,
87
+ src_len: 4,
88
+ newer: false,
89
+ len_mismatch: true,
90
+ file_mtime: :any,
91
+ coverage_timestamp: :any
92
+ },
93
+ expected_stale_char: 'L',
94
+ expected_error: CovLoupe::CoverageDataStaleError
95
+
96
+ it_behaves_like 'a staleness check',
97
+ description: 'treats missing file as stale',
98
+ file_lines: nil,
99
+ coverage_lines: [1, 1, 1],
100
+ timestamp: Time.now,
101
+ expected_details: {
102
+ exists: false,
103
+ newer: false,
104
+ len_mismatch: true,
105
+ file_mtime: nil,
106
+ coverage_timestamp: :any
107
+ },
108
+ expected_stale_char: 'M',
109
+ expected_error: CovLoupe::CoverageDataStaleError
110
+
111
+ it_behaves_like 'a staleness check',
112
+ description: 'is not stale when timestamps and lengths match',
113
+ file_lines: ['a', 'b', 'c'],
114
+ coverage_lines: [1, 0, nil],
115
+ timestamp: :past,
116
+ expected_details: {
117
+ exists: true,
118
+ newer: false,
119
+ len_mismatch: false,
120
+ file_mtime: :any,
121
+ coverage_timestamp: :any
122
+ },
123
+ expected_stale_char: false,
124
+ expected_error: nil
125
+ end
126
+
127
+ context 'when handling missing_trailing_newline? edge cases' do
128
+ let(:checker) do
129
+ described_class.new(root: tmpdir, resultset: nil, mode: 'off', timestamp: Time.now)
130
+ end
131
+
132
+ it 'detects file without trailing newline' do
133
+ file = File.join(tmpdir, 'no_newline.rb')
134
+ File.write(file, 'line1')
135
+ expect(checker.send(:missing_trailing_newline?, file)).to be true
136
+ end
137
+
138
+ it 'detects file with trailing newline (LF)' do
139
+ file = File.join(tmpdir, 'with_newline.rb')
140
+ File.write(file, "line1\n")
141
+ expect(checker.send(:missing_trailing_newline?, file)).to be false
142
+ end
143
+
144
+ it 'handles file with CRLF endings (Windows-style)' do
145
+ file = File.join(tmpdir, 'crlf.rb')
146
+ File.write(file, "line1\r\nline2\r\n", mode: 'wb')
147
+ expect(checker.send(:missing_trailing_newline?, file)).to be false
148
+ end
149
+
150
+ it 'handles file ending with CRLF but no final newline' do
151
+ file = File.join(tmpdir, 'crlf_no_final.rb')
152
+ File.write(file, "line1\r\nline2", mode: 'wb')
153
+ expect(checker.send(:missing_trailing_newline?, file)).to be true
154
+ end
155
+
156
+ it 'handles empty file' do
157
+ file = File.join(tmpdir, 'empty.rb')
158
+ File.write(file, '')
159
+ expect(checker.send(:missing_trailing_newline?, file)).to be false
160
+ end
161
+
162
+ it 'handles file with mixed line endings' do
163
+ file = File.join(tmpdir, 'mixed.rb')
164
+ File.write(file, "line1\nline2\r\nline3\n", mode: 'wb')
165
+ expect(checker.send(:missing_trailing_newline?, file)).to be false
166
+ end
167
+
168
+ it 'returns false for non-existent file' do
169
+ file = File.join(tmpdir, 'nonexistent.rb')
170
+ expect(checker.send(:missing_trailing_newline?, file)).to be false
171
+ end
172
+
173
+ it 'handles errors gracefully' do
174
+ file = File.join(tmpdir, 'test.rb')
175
+ File.write(file, 'content')
176
+
177
+ # Mock File.open to raise an error
178
+ allow(File).to receive(:open).with(file, 'rb').and_raise(StandardError.new('IO error'))
179
+
180
+ expect(checker.send(:missing_trailing_newline?, file)).to be false
181
+ end
182
+
183
+ it 'handles binary files that end with newline' do
184
+ file = File.join(tmpdir, 'binary.dat')
185
+ File.write(file, "\x00\x01\x02\x0A", mode: 'wb')
186
+ expect(checker.send(:missing_trailing_newline?, file)).to be false
187
+ end
188
+
189
+ it 'handles binary files that do not end with newline' do
190
+ file = File.join(tmpdir, 'binary_no_newline.dat')
191
+ File.write(file, "\x00\x01\x02\xFF", mode: 'wb')
192
+ expect(checker.send(:missing_trailing_newline?, file)).to be true
193
+ end
194
+ end
195
+
196
+ context 'when adjusting line count with missing trailing newline' do
197
+ let(:checker) do
198
+ described_class.new(root: tmpdir, resultset: nil, mode: 'off', timestamp: Time.now)
199
+ end
200
+
201
+ it 'adjusts line count when file has no trailing newline and counts differ by 1' do
202
+ file = File.join(tmpdir, 'adjust.rb')
203
+ # Write 3 lines without final newline
204
+ File.write(file, "line1\nline2\nline3")
205
+
206
+ # Coverage has 3 lines, file counts as 3 lines (no newline at end)
207
+ # but File.foreach will count 3 iterations
208
+ coverage_lines = [1, 0, 1]
209
+ details = checker.send(:compute_file_staleness_details, file, coverage_lines)
210
+
211
+ expect(details[:len_mismatch]).to be false
212
+ end
213
+
214
+ it 'does not adjust when file has trailing newline' do
215
+ file = File.join(tmpdir, 'no_adjust.rb')
216
+ # Write 3 lines with final newline
217
+ File.write(file, "line1\nline2\nline3\n")
218
+
219
+ # Coverage has 3 lines, file also counts as 3 lines (foreach counts by separator)
220
+ coverage_lines = [1, 0, 1]
221
+ details = checker.send(:compute_file_staleness_details, file, coverage_lines)
222
+
223
+ # No mismatch - both are 3 lines
224
+ expect(details[:src_len]).to eq(3)
225
+ expect(details[:cov_len]).to eq(3)
226
+ expect(details[:len_mismatch]).to be false
227
+ end
228
+
229
+ it 'does not adjust when difference is more than 1' do
230
+ file = File.join(tmpdir, 'big_diff.rb')
231
+ File.write(file, "line1\nline2\nline3\nline4\nline5")
232
+
233
+ coverage_lines = [1, 0, 1]
234
+ details = checker.send(:compute_file_staleness_details, file, coverage_lines)
235
+
236
+ expect(details[:len_mismatch]).to be true
237
+ end
238
+
239
+ it 'does not adjust when coverage is empty' do
240
+ file = File.join(tmpdir, 'empty_cov.rb')
241
+ File.write(file, "line1\nline2")
242
+
243
+ coverage_lines = []
244
+ details = checker.send(:compute_file_staleness_details, file, coverage_lines)
245
+
246
+ expect(details[:len_mismatch]).to be false
247
+ end
248
+ end
249
+
250
+ context 'when handling safe_count_lines edge cases' do
251
+ let(:checker) do
252
+ described_class.new(root: tmpdir, resultset: nil, mode: 'off', timestamp: Time.now)
253
+ end
254
+
255
+ it 'returns 0 for non-existent file' do
256
+ file = File.join(tmpdir, 'nonexistent.rb')
257
+ expect(checker.send(:safe_count_lines, file)).to eq(0)
258
+ end
259
+
260
+ it 'handles errors gracefully' do
261
+ file = File.join(tmpdir, 'test.rb')
262
+ File.write(file, "line1\nline2\n")
263
+
264
+ # Mock File.foreach to raise an error
265
+ allow(File).to receive(:foreach).with(file).and_raise(StandardError.new('IO error'))
266
+
267
+ expect(checker.send(:safe_count_lines, file)).to eq(0)
268
+ end
269
+
270
+ it 'counts lines correctly for file with final newline' do
271
+ file = File.join(tmpdir, 'with_newline.rb')
272
+ File.write(file, "line1\nline2\nline3\n")
273
+ # File.foreach counts 3 iterations (by line separator)
274
+ expect(checker.send(:safe_count_lines, file)).to eq(3)
275
+ end
276
+
277
+ it 'counts lines correctly for file without final newline' do
278
+ file = File.join(tmpdir, 'no_newline.rb')
279
+ File.write(file, "line1\nline2\nline3")
280
+ # File.foreach counts 3 iterations
281
+ expect(checker.send(:safe_count_lines, file)).to eq(3)
282
+ end
283
+
284
+ it 'returns 0 for empty file' do
285
+ file = File.join(tmpdir, 'empty.rb')
286
+ File.write(file, '')
287
+ expect(checker.send(:safe_count_lines, file)).to eq(0)
288
+ end
289
+ end
290
+
291
+ context 'when rel has path prefix mismatches' do
292
+ let(:checker) do
293
+ described_class.new(root: tmpdir, resultset: nil, mode: 'off', timestamp: Time.now)
294
+ end
295
+
296
+ it 'returns relative path for files within project root' do
297
+ file_inside = File.join(tmpdir, 'lib', 'test.rb')
298
+ expect(checker.send(:rel, file_inside)).to eq('lib/test.rb')
299
+ end
300
+
301
+ it 'handles ArgumentError when path prefixes differ (absolute vs relative)' do
302
+ # Test the specific ArgumentError scenario: absolute path vs relative root
303
+ # This simulates the bug scenario where coverage data has absolute paths
304
+ # but the root is somehow processed as relative (edge case)
305
+ checker_with_relative_root = described_class.new(root: '.', resultset: nil, mode: 'off',
306
+ timestamp: Time.now)
307
+
308
+ # Override the @root to simulate the edge case where it's still relative
309
+ checker_with_relative_root.instance_variable_set(:@root, './subdir')
310
+
311
+ file_absolute = '/opt/shared_libs/utils/validation.rb'
312
+
313
+ # This should trigger the ArgumentError rescue and return the absolute path
314
+ expect(checker_with_relative_root.send(:rel, file_absolute))
315
+ .to eq('/opt/shared_libs/utils/validation.rb')
316
+ end
317
+
318
+ it 'handles relative file paths with absolute root' do
319
+ file_relative = './lib/test.rb'
320
+
321
+ # This should work fine (both are converted to absolute internally)
322
+ expect { checker.send(:rel, file_relative) }.not_to raise_error
323
+ end
324
+
325
+ it 'works with check_file! when rel encounters ArgumentError' do
326
+ # Test the specific case where rel() would crash with ArgumentError
327
+ # Instead of testing the full check_file! flow, just test that rel() works
328
+
329
+ checker_with_edge_case = described_class.new(root: '.', resultset: nil, mode: 'off',
330
+ timestamp: Time.now)
331
+ checker_with_edge_case.instance_variable_set(:@root, './subdir')
332
+
333
+ file_outside = '/opt/company_gem/lib/core.rb'
334
+
335
+ # This should trigger the ArgumentError and return the absolute path
336
+ # instead of crashing with ArgumentError
337
+ result = checker_with_edge_case.send(:rel, file_outside)
338
+ expect(result).to eq('/opt/company_gem/lib/core.rb')
339
+
340
+ # Verify it doesn't raise ArgumentError
341
+ expect { checker_with_edge_case.send(:rel, file_outside) }.not_to raise_error
342
+ end
343
+
344
+ it 'handles files outside project root gracefully (returns relative path with ..)' do
345
+ # Test that normal "outside but compatible" paths still work
346
+ file_outside = '/tmp/external_file.rb'
347
+
348
+ # This should return a relative path with .. (not trigger ArgumentError)
349
+ result = checker.send(:rel, file_outside)
350
+ expect(result).to include('..') # Should contain relative navigation
351
+ expect(result).not_to start_with('/') # Should be relative, not absolute
352
+ end
353
+
354
+ it 'allows project-level staleness checks to handle coverage outside root' do
355
+ future_time = Time.at(Time.now.to_i + 3600)
356
+ checker_with_relative_root = described_class.new(root: '.', resultset: nil, mode: 'error',
357
+ timestamp: future_time)
358
+ checker_with_relative_root.instance_variable_set(:@root, './subdir')
359
+
360
+ external_dir = Dir.mktmpdir('scmcp-outside')
361
+
362
+ begin
363
+ external_file = File.join(external_dir, 'shared.rb')
364
+ File.write(external_file, "puts 'hi'\n")
365
+
366
+ coverage_map = { external_file => [1] }
367
+
368
+ expect { checker_with_relative_root.check_project!(coverage_map) }.not_to raise_error
369
+ ensure
370
+ FileUtils.remove_entry(external_dir) if external_dir && File.directory?(external_dir)
371
+ end
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe do
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+
8
+ describe CovLoupe::CoverageModel do
9
+ it 'raises file-level stale when source and coverage lengths differ' do
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
+ })
14
+ model = described_class.new(root: root, resultset: 'coverage', staleness: :error)
15
+ # bar.rb has 2 coverage entries but 3 source lines in fixtures
16
+ expect do
17
+ model.summary_for('lib/bar.rb')
18
+ end.to raise_error(CovLoupe::CoverageDataStaleError, /stale/i)
19
+ end
20
+ end
21
+
22
+ describe CovLoupe::StalenessChecker do
23
+ it 'flags deleted files present only in coverage' do
24
+ checker = described_class.new(root: root, resultset: 'coverage', mode: :error,
25
+ timestamp: Time.now.to_i)
26
+ coverage_map = {
27
+ File.join(root, 'lib', 'does_not_exist_anymore.rb') => { 'lines' => [1] }
28
+ }
29
+ expect do
30
+ checker.check_project!(coverage_map)
31
+ end.to raise_error(CovLoupe::CoverageDataProjectStaleError)
32
+ end
33
+
34
+ it 'does not raise for empty tracked_globs when nothing else is stale' do
35
+ checker = described_class.new(root: root, resultset: 'coverage', mode: :error,
36
+ tracked_globs: [], timestamp: Time.now.to_i)
37
+ expect do
38
+ checker.check_project!({})
39
+ end.not_to raise_error
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CLI test helpers
4
+ module CLITestHelpers
5
+ # Run CLI with the given arguments and return [stdout, stderr, exit_status]
6
+ def run_cli_with_status(*argv)
7
+ cli = CovLoupe::CoverageCLI.new
8
+ status = nil
9
+ out_str = err_str = nil
10
+ silence_output do |out, err|
11
+ begin
12
+ cli.run(argv.flatten)
13
+ status = 0
14
+ rescue SystemExit => e
15
+ status = e.status
16
+ end
17
+ out_str = out.string
18
+ err_str = err.string
19
+ end
20
+ [out_str, err_str, status]
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Helpers for managing control flow in RSpec tests.
4
+ module ControlFlowHelpers
5
+ # Execute a block that's expected to call exit() without terminating the test.
6
+ # Useful for testing CLI commands that normally exit.
7
+ # Returns the exit status code if exit was called, otherwise returns the block's value.
8
+ #
9
+ # Examples:
10
+ # status = swallow_system_exit { cli.run(['--help']) }
11
+ # expect(status).to eq(0) # --help calls exit(0)
12
+ #
13
+ # result = swallow_system_exit { some_computation }
14
+ # expect(result).to eq(expected_value) # no exit, returns block value
15
+ def swallow_system_exit
16
+ yield
17
+ rescue SystemExit => e
18
+ e.status
19
+ end
20
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FakeMCP
4
+ # Fake server captures the last created instance so we can assert on the
5
+ # name/version/tools passed in by CovLoupe::MCPServer.
6
+ class Server
7
+ class << self
8
+ attr_accessor :last_instance
9
+ end
10
+ attr_reader :params
11
+
12
+ def initialize(name:, version:, tools:)
13
+ @params = { name: name, version: version, tools: tools }
14
+ self.class.last_instance = self
15
+ end
16
+ end
17
+
18
+ # Fake stdio transport records whether `open` was called and the server
19
+ # it was initialized with.
20
+ class StdioTransport
21
+ class << self
22
+ attr_accessor :last_instance
23
+ end
24
+ attr_reader :server, :opened
25
+
26
+ def initialize(server)
27
+ @server = server
28
+ @opened = false
29
+ self.class.last_instance = self
30
+ end
31
+
32
+ def open
33
+ @opened = true
34
+ end
35
+
36
+ def opened?
37
+ @opened
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared test helpers for I/O operations (e.g., capturing stdout/stderr).
4
+ module TestIOHelpers
5
+ # Suppress stdout/stderr within the given block, yielding the StringIOs
6
+ def silence_output
7
+ original_stdout = $stdout
8
+ original_stderr = $stderr
9
+ $stdout = StringIO.new
10
+ $stderr = StringIO.new
11
+ yield $stdout, $stderr
12
+ ensure
13
+ $stdout = original_stdout
14
+ $stderr = original_stderr
15
+ end
16
+
17
+ # Capture the output of a command execution
18
+ # @param command [CovLoupe::Commands::BaseCommand] The command instance to execute
19
+ # @param args [Array] The arguments to pass to execute
20
+ # @return [String] The captured output
21
+ def capture_command_output(command, args)
22
+ output = nil
23
+ silence_output do |stdout, _stderr|
24
+ command.execute(args.dup)
25
+ output = stdout.string
26
+ end
27
+ output
28
+ end
29
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # MCP Tool shared examples and helpers
4
+ module MCPToolTestHelpers
5
+ def setup_mcp_response_stub
6
+ # Standardized MCP::Tool::Response stub that works for all tools
7
+ response_class = Class.new do
8
+ attr_reader :payload, :meta
9
+
10
+ def initialize(payload, meta: nil)
11
+ @payload = payload
12
+ @meta = meta
13
+ end
14
+ end
15
+ stub_const('MCP::Tool::Response', response_class)
16
+ end
17
+
18
+ def expect_mcp_text_json(response, expected_keys: [])
19
+ item = response.payload.first
20
+
21
+ # Check for a 'text' part
22
+ expect(item['type']).to eq('text')
23
+ expect(item).to have_key('text')
24
+
25
+ # Parse and validate JSON content
26
+ data = JSON.parse(item['text'])
27
+
28
+ # Check for expected keys
29
+ expected_keys.each do |key|
30
+ expect(data).to have_key(key)
31
+ end
32
+
33
+ [data, item] # Return for additional custom assertions
34
+ end
35
+ end