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,66 @@
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 `cov-loupe` 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 def call(requests: nil, input: nil, env: {}, lib_path:, exe_path:, timeout: 5,
19
+ close_stdin: true)
20
+ payload = build_payload(requests, input)
21
+
22
+ stdout_str = ''
23
+ stderr_str = ''
24
+ status = nil
25
+
26
+ Open3.popen3(env, 'ruby', '-I', lib_path, exe_path) do |stdin, stdout, stderr, wait_thr|
27
+ unless payload.nil?
28
+ stdin.write(payload)
29
+ stdin.write("\n") if !payload.empty? && !payload.end_with?("\n")
30
+ end
31
+ stdin.close if close_stdin
32
+
33
+ Timeout.timeout(timeout) do
34
+ stdout_str = stdout.read
35
+ stderr_str = stderr.read
36
+ status = wait_thr.value
37
+ end
38
+ end
39
+
40
+ { stdout: stdout_str, stderr: stderr_str, status: status }
41
+ rescue Timeout::Error
42
+ raise "MCP server timed out after #{timeout} seconds"
43
+ end
44
+
45
+ module_function def call_json(request_hash, input: nil, env: {}, lib_path:, exe_path:,
46
+ timeout: 5, close_stdin: true)
47
+ call(requests: request_hash, input: input, env: env, lib_path: lib_path,
48
+ exe_path: exe_path, timeout: timeout, close_stdin: close_stdin)
49
+ end
50
+
51
+ module_function def call_json_stream(request_hashes, input: nil, env: {}, lib_path:,
52
+ exe_path:, timeout: 5, close_stdin: true)
53
+ call(requests: Array(request_hashes), input: input, env: env, lib_path: lib_path,
54
+ exe_path: exe_path, timeout: timeout, close_stdin: close_stdin)
55
+ end
56
+
57
+ module_function def build_payload(requests, input)
58
+ return input unless requests
59
+
60
+ normalized = requests.is_a?(Array) ? requests : [requests]
61
+ normalized.map { |req| req.is_a?(String) ? req : JSON.generate(req) }.join("\n")
62
+ end
63
+ private_class_method :build_payload
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Helpers for mocking and stubbing objects in RSpec tests.
4
+ module MockingHelpers
5
+ # Stub staleness checking to return a specific value
6
+ # @param value [String, false] The staleness value to return ('L', 'T', 'M', or false)
7
+ def stub_staleness_check(value)
8
+ checker_double = instance_double(CovLoupe::StalenessChecker)
9
+ allow(checker_double).to receive_messages(
10
+ stale_for_file?: value,
11
+ off?: false
12
+ )
13
+ allow(checker_double).to receive(:check_file!)
14
+ allow(CovLoupe::StalenessChecker).to receive(:new).and_return(checker_double)
15
+ end
16
+
17
+ # Stub a presenter with specific payload data
18
+ # @param presenter_class [Class] The presenter class to stub (e.g., CovLoupe::Presenters::CoverageRawPresenter)
19
+ # @param absolute_payload [Hash] The data hash to return from #absolute_payload
20
+ # @param relative_path [String] The path to return from #relative_path
21
+ def mock_presenter(presenter_class, absolute_payload:, relative_path:)
22
+ presenter_double = instance_double(presenter_class)
23
+ allow(presenter_double).to receive_messages(
24
+ absolute_payload: absolute_payload,
25
+ relative_path: relative_path
26
+ )
27
+ allow(presenter_class).to receive(:new).and_return(presenter_double)
28
+ presenter_double
29
+ end
30
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::CoverageCLI, 'table format for all commands' do
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+
8
+ def run_cli(*argv)
9
+ cli = CovLoupe::CoverageCLI.new
10
+ output = nil
11
+ silence_output do |stdout, _stderr|
12
+ cli.send(:run, argv)
13
+ output = stdout.string
14
+ end
15
+ output
16
+ end
17
+
18
+ describe 'table format consistency' do
19
+ it 'list command produces formatted table' do
20
+ output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'table', 'list')
21
+ expect(output).to include('│') # Box drawing character
22
+ expect(output).to include('File')
23
+ expect(output).to include('%')
24
+ end
25
+
26
+ it 'summary command produces formatted table' do
27
+ output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'table',
28
+ 'summary', 'lib/foo.rb')
29
+ expect(output).to include('│') # Box drawing character
30
+ expect(output).to include('File')
31
+ expect(output).to include('%')
32
+ end
33
+
34
+ it 'totals command produces formatted table' do
35
+ output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'table', 'totals')
36
+ expect(output).to include('│') # Box drawing character
37
+ expect(output).to include('Lines')
38
+ expect(output).to include('%')
39
+ end
40
+
41
+ it 'detailed command produces formatted table' do
42
+ output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'table',
43
+ 'detailed', 'lib/foo.rb')
44
+ expect(output).to include('│') # Box drawing character
45
+ expect(output).to include('Line')
46
+ expect(output).to include('Hits')
47
+ end
48
+
49
+ it 'uncovered command produces formatted table' do
50
+ output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'table',
51
+ 'uncovered', 'lib/bar.rb') # bar.rb has uncovered lines
52
+ expect(output).to include('│') # Box drawing character
53
+ expect(output).to include('Line')
54
+ end
55
+
56
+ it 'raw command produces formatted table' do
57
+ output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'table',
58
+ 'raw', 'lib/foo.rb')
59
+ expect(output).to include('│') # Box drawing character
60
+ expect(output).to include('Line')
61
+ expect(output).to include('Coverage')
62
+ end
63
+
64
+ it 'version command produces formatted table' do
65
+ output = run_cli('--format', 'table', 'version')
66
+ expect(output).to include('│') # Box drawing character
67
+ expect(output).to include('Version')
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'pathname'
5
+ require 'tempfile'
6
+ require 'cov_loupe/tools/validate_tool'
7
+
8
+ RSpec.describe CovLoupe::Tools::ValidateTool do
9
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
10
+ let(:resultset) { 'coverage' }
11
+ let(:server_context) { instance_double('ServerContext').as_null_object }
12
+
13
+ before do
14
+ setup_mcp_response_stub
15
+ end
16
+
17
+ def call_tool(**params)
18
+ described_class.call(**params, root: root, resultset: resultset, server_context: server_context)
19
+ end
20
+
21
+ def response_text(response)
22
+ item = response.payload.first
23
+ item['text']
24
+ end
25
+
26
+ def with_predicate_file(content, dir: nil)
27
+ Tempfile.create(['predicate', '.rb'], dir) do |file|
28
+ file.write(content)
29
+ file.flush
30
+ yield file
31
+ end
32
+ end
33
+
34
+ shared_examples 'syntax error handling' do |_source, error_message_fragment|
35
+ it 'returns an error for syntax errors' do
36
+ response = call_with_predicate('->(_m) { 1 + }')
37
+
38
+ expect(response_text(response)).to include(error_message_fragment)
39
+ end
40
+ end
41
+
42
+ shared_examples 'non-callable handling' do |_source, content|
43
+ it 'returns an error when the predicate is not callable' do
44
+ response = call_with_predicate(content)
45
+
46
+ expect(response_text(response)).to include('Predicate must be callable')
47
+ end
48
+ end
49
+
50
+ shared_examples 'false result' do
51
+ it 'returns false when the predicate evaluates to false' do
52
+ response = call_with_predicate('->(_m) { false }')
53
+
54
+ data, = expect_mcp_text_json(response, expected_keys: ['result'])
55
+ expect(data['result']).to be(false)
56
+ end
57
+ end
58
+
59
+ describe '.call' do
60
+ context 'with inline code' do
61
+ def call_with_predicate(code)
62
+ call_tool(code: code)
63
+ end
64
+
65
+ it 'evaluates the predicate against the coverage model' do
66
+ expect(CovLoupe::CoverageModel).to receive(:new).and_call_original
67
+
68
+ # Realistic coverage policy: foo.rb must have at least 50% coverage
69
+ response = call_with_predicate(
70
+ '->(m) { m.all_files.detect { |f| f["file"].include?("foo.rb") }["percentage"] >= 50.0 }'
71
+ )
72
+
73
+ data, = expect_mcp_text_json(response, expected_keys: ['result'])
74
+ expect(data['result']).to be(true)
75
+ end
76
+
77
+ it_behaves_like 'false result'
78
+ it_behaves_like 'syntax error handling', :code, 'Syntax error in predicate code'
79
+ it_behaves_like 'non-callable handling', :code, '123'
80
+
81
+ it 'returns an error when the predicate raises during execution' do
82
+ response = call_with_predicate("->(_m) { raise 'Boom' }")
83
+
84
+ text = response_text(response)
85
+ expect(text).to include('Error:', 'Boom')
86
+ # Verify it's an error response, not a JSON result
87
+ expect(text).not_to match(/\{"result"/)
88
+ end
89
+ end
90
+
91
+ context 'with a predicate file' do
92
+ def call_with_predicate(content)
93
+ with_predicate_file(content) do |file|
94
+ call_tool(file: file.path)
95
+ end
96
+ end
97
+
98
+ it_behaves_like 'false result'
99
+ it_behaves_like 'syntax error handling', :file, 'Syntax error in predicate file'
100
+ it_behaves_like 'non-callable handling', :file, 'true'
101
+
102
+ it 'expands relative paths from the provided root before evaluation' do
103
+ with_predicate_file('->(_m) { true }', dir: root) do |file|
104
+ relative_path = Pathname.new(file.path).relative_path_from(Pathname.new(root)).to_s
105
+ allow(CovLoupe::PredicateEvaluator)
106
+ .to receive(:evaluate_file)
107
+ .and_return(true)
108
+
109
+ response = call_tool(file: relative_path)
110
+
111
+ expect(CovLoupe::PredicateEvaluator)
112
+ .to have_received(:evaluate_file)
113
+ .with(file.path, kind_of(CovLoupe::CoverageModel))
114
+ data, = expect_mcp_text_json(response, expected_keys: ['result'])
115
+ expect(data['result']).to be(true)
116
+ end
117
+ end
118
+
119
+ it 'returns an error when the predicate file is missing' do
120
+ response = call_tool(file: 'missing_predicate.rb')
121
+
122
+ expect(response_text(response)).to include('Predicate file not found')
123
+ end
124
+ end
125
+
126
+ it 'returns an error when neither code nor file is provided' do
127
+ response = call_tool
128
+
129
+ expect(response_text(response)).to include("Either 'code' or 'file' must be provided")
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'cov_loupe/tools/help_tool'
5
+ require 'cov_loupe/tools/version_tool'
6
+ require 'cov_loupe/tools/coverage_summary_tool'
7
+ require 'cov_loupe/tools/coverage_raw_tool'
8
+ require 'cov_loupe/tools/uncovered_lines_tool'
9
+ require 'cov_loupe/tools/coverage_detailed_tool'
10
+ require 'cov_loupe/tools/coverage_totals_tool'
11
+
12
+ RSpec.describe CovLoupe::Tools do
13
+ let(:server_context) { instance_double('ServerContext').as_null_object }
14
+
15
+ before do
16
+ setup_mcp_response_stub
17
+ end
18
+
19
+ # NOTE: VersionTool error handling is difficult to test because the tool is so simple
20
+ # and doesn't have any complex logic that could fail. The rescue clause in the tool
21
+ # exists for consistency with other tools but is unlikely to be triggered in practice.
22
+
23
+ describe CovLoupe::Tools::HelpTool do
24
+ it 'returns tool information without errors' do
25
+ response = described_class.call(error_mode: 'log', server_context: server_context)
26
+
27
+ expect(response).to be_a(MCP::Tool::Response)
28
+ item = response.payload.first
29
+ expect(item[:type] || item['type']).to eq('text')
30
+
31
+ data = JSON.parse(item['text'])
32
+ expect(data).to have_key('tools')
33
+ expect(data['tools']).not_to be_empty
34
+ end
35
+ end
36
+
37
+ describe CovLoupe::Tools::CoverageSummaryTool do
38
+ it 'handles errors during model creation' do
39
+ allow(CovLoupe::CoverageModel).to receive(:new).and_raise(StandardError, 'Model error')
40
+
41
+ response = described_class.call(
42
+ path: 'lib/foo.rb',
43
+ error_mode: 'log',
44
+ server_context: server_context
45
+ )
46
+
47
+ # Should return error response
48
+ expect(response).to be_a(MCP::Tool::Response)
49
+ item = response.payload.first
50
+ expect(item[:type] || item['type']).to eq('text')
51
+ expect(item['text']).to include('Error')
52
+ end
53
+ end
54
+
55
+ describe CovLoupe::Tools::CoverageRawTool do
56
+ it 'handles errors during raw data retrieval' do
57
+ model = instance_double(CovLoupe::CoverageModel)
58
+ allow(CovLoupe::CoverageModel).to receive(:new).and_return(model)
59
+ allow(model).to receive(:raw_for).and_raise(StandardError, 'Raw data error')
60
+
61
+ response = described_class.call(
62
+ path: 'lib/foo.rb',
63
+ error_mode: 'log',
64
+ server_context: server_context
65
+ )
66
+
67
+ # Should return error response
68
+ expect(response).to be_a(MCP::Tool::Response)
69
+ item = response.payload.first
70
+ expect(item[:type] || item['type']).to eq('text')
71
+ expect(item['text']).to include('Error')
72
+ end
73
+ end
74
+
75
+ describe CovLoupe::Tools::UncoveredLinesTool do
76
+ it 'handles errors during uncovered lines retrieval' do
77
+ model = instance_double(CovLoupe::CoverageModel)
78
+ allow(CovLoupe::CoverageModel).to receive(:new).and_return(model)
79
+ allow(model).to receive(:uncovered_for).and_raise(StandardError, 'Uncovered error')
80
+
81
+ response = described_class.call(
82
+ path: 'lib/foo.rb',
83
+ error_mode: 'log',
84
+ server_context: server_context
85
+ )
86
+
87
+ # Should return error response
88
+ expect(response).to be_a(MCP::Tool::Response)
89
+ item = response.payload.first
90
+ expect(item[:type] || item['type']).to eq('text')
91
+ expect(item['text']).to include('Error')
92
+ end
93
+ end
94
+
95
+ describe CovLoupe::Tools::CoverageDetailedTool do
96
+ it 'handles errors during detailed data retrieval' do
97
+ model = instance_double(CovLoupe::CoverageModel)
98
+ allow(CovLoupe::CoverageModel).to receive(:new).and_return(model)
99
+ allow(model).to receive(:detailed_for).and_raise(StandardError, 'Detailed error')
100
+
101
+ response = described_class.call(
102
+ path: 'lib/foo.rb',
103
+ error_mode: 'log',
104
+ server_context: server_context
105
+ )
106
+
107
+ # Should return error response
108
+ expect(response).to be_a(MCP::Tool::Response)
109
+ item = response.payload.first
110
+ expect(item[:type] || item['type']).to eq('text')
111
+ expect(item['text']).to include('Error')
112
+ end
113
+ end
114
+
115
+ describe CovLoupe::Tools::CoverageTotalsTool do
116
+ it 'handles errors during totals calculation' do
117
+ allow(CovLoupe::CoverageModel).to receive(:new).and_raise(StandardError, 'Model error')
118
+
119
+ response = described_class.call(
120
+ error_mode: 'log',
121
+ server_context: server_context
122
+ )
123
+
124
+ expect(response).to be_a(MCP::Tool::Response)
125
+ item = response.payload.first
126
+ expect(item[:type] || item['type']).to eq('text')
127
+ expect(item['text']).to include('Error')
128
+ end
129
+ end
130
+ end
data/spec/util_spec.rb ADDED
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tempfile'
5
+
6
+ RSpec.describe CovLoupe::CovUtil do
7
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
8
+ let(:resultset_file) { File.join(root, 'coverage', '.resultset.json') }
9
+
10
+
11
+
12
+ it 'lookup_lines supports cwd-stripping' do
13
+ lines = [1, 0]
14
+
15
+ # Exact key
16
+ cov = { '/abs/path/foo.rb' => { 'lines' => lines } }
17
+ expect(described_class.lookup_lines(cov, '/abs/path/foo.rb')).to eq(lines)
18
+
19
+ # CWD strip fallback
20
+ allow(Dir).to receive(:pwd).and_return('/cwd')
21
+ cov = { 'sub/foo.rb' => { 'lines' => lines } }
22
+ expect(described_class.lookup_lines(cov, '/cwd/sub/foo.rb')).to eq(lines)
23
+
24
+ # Different paths with same basename should not match
25
+ cov = { '/some/where/else/foo.rb' => { 'lines' => lines } }
26
+ expect do
27
+ described_class.lookup_lines(cov, '/another/place/foo.rb')
28
+ end.to raise_error(CovLoupe::FileError, /No coverage entry found/)
29
+
30
+ # Missing raises a FileError
31
+ cov = {}
32
+ expect do
33
+ described_class.lookup_lines(cov, '/nowhere/foo.rb')
34
+ end.to raise_error(CovLoupe::FileError, /No coverage entry found/)
35
+ end
36
+
37
+ it 'summary handles edge cases and coercion' do
38
+ expect(described_class.summary([]))
39
+ .to include('percentage' => 100.0, 'total' => 0, 'covered' => 0)
40
+ expect(described_class.summary([nil, nil]))
41
+ .to include('percentage' => 100.0, 'total' => 0, 'covered' => 0)
42
+ expect(described_class.summary(['1', '0', nil]))
43
+ .to include('percentage' => 50.0, 'total' => 2, 'covered' => 1)
44
+ end
45
+
46
+ it 'uncovered and detailed ignore nils' do
47
+ arr = [1, 0, nil, 2]
48
+ expect(described_class.uncovered(arr)).to eq([2])
49
+ expect(described_class.detailed(arr)).to eq([
50
+ { 'line' => 1, 'hits' => 1, 'covered' => true },
51
+ { 'line' => 2, 'hits' => 0, 'covered' => false },
52
+ { 'line' => 4, 'hits' => 2, 'covered' => true }
53
+ ])
54
+ end
55
+
56
+ it 'load_coverage raises CoverageDataError on invalid JSON via model' do
57
+ Dir.mktmpdir do |dir|
58
+ bad = File.join(dir, '.resultset.json')
59
+ File.write(bad, '{not-json')
60
+ expect do
61
+ CovLoupe::CoverageModel.new(root: root, resultset: dir)
62
+ end.to raise_error(CovLoupe::CoverageDataError, /Invalid coverage data format/)
63
+ end
64
+ end
65
+
66
+ describe 'logging configuration' do
67
+ let(:test_message) { 'test log message' }
68
+
69
+ around do |example|
70
+ # Reset logging settings so each example starts clean.
71
+ old_default = CovLoupe.default_log_file
72
+ old_active = CovLoupe.active_log_file
73
+ CovLoupe.default_log_file = nil
74
+ CovLoupe.active_log_file = nil
75
+
76
+ example.run
77
+
78
+ # Restore state
79
+ CovLoupe.default_log_file = old_default
80
+ CovLoupe.active_log_file = old_active
81
+ end
82
+
83
+
84
+
85
+ it "logs to stdout when active_log_file is 'stdout'" do
86
+ CovLoupe.active_log_file = 'stdout'
87
+ expect(File).not_to receive(:open)
88
+ expect { described_class.log(test_message) }
89
+ .to output(/#{Regexp.escape(test_message)}/).to_stdout
90
+ end
91
+
92
+ it "logs to stderr when active_log_file is 'stderr'" do
93
+ CovLoupe.active_log_file = 'stderr'
94
+ expect(File).not_to receive(:open)
95
+ expect { described_class.log(test_message) }
96
+ .to output(/#{Regexp.escape(test_message)}/).to_stderr
97
+ end
98
+
99
+ it 'log writes to file when path is configured' do
100
+ tmp = Tempfile.new('cov_loupe-log')
101
+ log_path = tmp.path
102
+ tmp.close
103
+
104
+ CovLoupe.active_log_file = log_path
105
+
106
+ described_class.log(test_message)
107
+
108
+ expect(File.exist?(log_path)).to be true
109
+ content = File.read(log_path)
110
+ expect(content).to include(test_message)
111
+ expect(content).to match(TIMESTAMP_REGEX)
112
+ ensure
113
+ tmp&.unlink
114
+ end
115
+
116
+ it 'log respects runtime changes disabling logging mid-run' do
117
+ tmp = Tempfile.new('cov_loupe-log')
118
+ log_path = tmp.path
119
+ tmp.close
120
+
121
+ CovLoupe.active_log_file = log_path
122
+
123
+ described_class.log('first entry')
124
+ expect(File.exist?(log_path)).to be true
125
+ first_content = File.read(log_path)
126
+ expect(first_content).to include('first entry')
127
+
128
+ CovLoupe.active_log_file = 'stderr'
129
+
130
+ expect { described_class.log('second entry') }
131
+ .to output(/second entry/).to_stderr
132
+ expect(File.exist?(log_path)).to be true
133
+ expect(File.read(log_path)).to eq(first_content)
134
+ ensure
135
+ tmp&.unlink
136
+ end
137
+
138
+ it 'exposes default log file configuration separately' do
139
+ original_default = CovLoupe.default_log_file
140
+ CovLoupe.default_log_file = 'stderr'
141
+ expect(CovLoupe.default_log_file).to eq('stderr')
142
+ expect(CovLoupe.active_log_file).to eq('stderr')
143
+ ensure
144
+ CovLoupe.default_log_file = original_default
145
+ end
146
+
147
+ it 'allows adjusting the active log target without touching the default' do
148
+ original_default = CovLoupe.default_log_file
149
+ CovLoupe.active_log_file = 'stdout'
150
+ expect(CovLoupe.active_log_file).to eq('stdout')
151
+ expect(CovLoupe.default_log_file).to eq(original_default)
152
+ end
153
+ end
154
+ end