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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +329 -0
- data/docs/dev/ARCHITECTURE.md +80 -0
- data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
- data/docs/dev/DEVELOPMENT.md +83 -0
- data/docs/dev/README.md +10 -0
- data/docs/dev/RELEASING.md +146 -0
- data/docs/dev/arch-decisions/001-x-arch-decision.md +95 -0
- data/docs/dev/arch-decisions/002-x-arch-decision.md +159 -0
- data/docs/dev/arch-decisions/003-x-arch-decision.md +165 -0
- data/docs/dev/arch-decisions/004-x-arch-decision.md +203 -0
- data/docs/dev/arch-decisions/005-x-arch-decision.md +189 -0
- data/docs/dev/arch-decisions/README.md +60 -0
- data/docs/dev/presentations/cov-loupe-presentation.md +255 -0
- data/docs/fixtures/demo_project/README.md +9 -0
- data/docs/user/ADVANCED_USAGE.md +777 -0
- data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
- data/docs/user/CLI_USAGE.md +750 -0
- data/docs/user/ERROR_HANDLING.md +93 -0
- data/docs/user/EXAMPLES.md +588 -0
- data/docs/user/INSTALLATION.md +130 -0
- data/docs/user/LIBRARY_API.md +693 -0
- data/docs/user/MCP_INTEGRATION.md +490 -0
- data/docs/user/README.md +14 -0
- data/docs/user/TROUBLESHOOTING.md +197 -0
- data/docs/user/V2-BREAKING-CHANGES.md +472 -0
- data/exe/cov-loupe +23 -0
- data/lib/cov_loupe/app_config.rb +56 -0
- data/lib/cov_loupe/app_context.rb +26 -0
- data/lib/cov_loupe/base_tool.rb +102 -0
- data/lib/cov_loupe/cli.rb +178 -0
- data/lib/cov_loupe/commands/base_command.rb +67 -0
- data/lib/cov_loupe/commands/command_factory.rb +45 -0
- data/lib/cov_loupe/commands/detailed_command.rb +38 -0
- data/lib/cov_loupe/commands/list_command.rb +13 -0
- data/lib/cov_loupe/commands/raw_command.rb +38 -0
- data/lib/cov_loupe/commands/summary_command.rb +41 -0
- data/lib/cov_loupe/commands/totals_command.rb +53 -0
- data/lib/cov_loupe/commands/uncovered_command.rb +45 -0
- data/lib/cov_loupe/commands/validate_command.rb +60 -0
- data/lib/cov_loupe/commands/version_command.rb +33 -0
- data/lib/cov_loupe/config_parser.rb +32 -0
- data/lib/cov_loupe/constants.rb +22 -0
- data/lib/cov_loupe/coverage_reporter.rb +31 -0
- data/lib/cov_loupe/error_handler.rb +165 -0
- data/lib/cov_loupe/error_handler_factory.rb +31 -0
- data/lib/cov_loupe/errors.rb +191 -0
- data/lib/cov_loupe/formatters/source_formatter.rb +152 -0
- data/lib/cov_loupe/formatters.rb +51 -0
- data/lib/cov_loupe/mcp_server.rb +42 -0
- data/lib/cov_loupe/mode_detector.rb +56 -0
- data/lib/cov_loupe/model.rb +339 -0
- data/lib/cov_loupe/option_normalizers.rb +113 -0
- data/lib/cov_loupe/option_parser_builder.rb +147 -0
- data/lib/cov_loupe/option_parsers/env_options_parser.rb +48 -0
- data/lib/cov_loupe/option_parsers/error_helper.rb +110 -0
- data/lib/cov_loupe/path_relativizer.rb +64 -0
- data/lib/cov_loupe/predicate_evaluator.rb +72 -0
- data/lib/cov_loupe/presenters/base_coverage_presenter.rb +42 -0
- data/lib/cov_loupe/presenters/coverage_detailed_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/coverage_raw_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/coverage_summary_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/coverage_uncovered_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/project_coverage_presenter.rb +50 -0
- data/lib/cov_loupe/presenters/project_totals_presenter.rb +27 -0
- data/lib/cov_loupe/resolvers/coverage_line_resolver.rb +122 -0
- data/lib/cov_loupe/resolvers/resolver_factory.rb +28 -0
- data/lib/cov_loupe/resolvers/resultset_path_resolver.rb +76 -0
- data/lib/cov_loupe/resultset_loader.rb +131 -0
- data/lib/cov_loupe/staleness_checker.rb +247 -0
- data/lib/cov_loupe/table_formatter.rb +64 -0
- data/lib/cov_loupe/tools/all_files_coverage_tool.rb +51 -0
- data/lib/cov_loupe/tools/coverage_detailed_tool.rb +35 -0
- data/lib/cov_loupe/tools/coverage_raw_tool.rb +34 -0
- data/lib/cov_loupe/tools/coverage_summary_tool.rb +34 -0
- data/lib/cov_loupe/tools/coverage_table_tool.rb +50 -0
- data/lib/cov_loupe/tools/coverage_totals_tool.rb +44 -0
- data/lib/cov_loupe/tools/help_tool.rb +115 -0
- data/lib/cov_loupe/tools/uncovered_lines_tool.rb +34 -0
- data/lib/cov_loupe/tools/validate_tool.rb +72 -0
- data/lib/cov_loupe/tools/version_tool.rb +32 -0
- data/lib/cov_loupe/util.rb +88 -0
- data/lib/cov_loupe/version.rb +5 -0
- data/lib/cov_loupe.rb +140 -0
- data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
- data/spec/TIMESTAMPS.md +48 -0
- data/spec/all_files_coverage_tool_spec.rb +53 -0
- data/spec/app_config_spec.rb +142 -0
- data/spec/base_tool_spec.rb +62 -0
- data/spec/cli/show_default_report_spec.rb +33 -0
- data/spec/cli_enumerated_options_spec.rb +90 -0
- data/spec/cli_error_spec.rb +184 -0
- data/spec/cli_format_spec.rb +123 -0
- data/spec/cli_json_options_spec.rb +50 -0
- data/spec/cli_source_spec.rb +44 -0
- data/spec/cli_spec.rb +192 -0
- data/spec/cli_table_spec.rb +28 -0
- data/spec/cli_usage_spec.rb +42 -0
- data/spec/commands/base_command_spec.rb +107 -0
- data/spec/commands/command_factory_spec.rb +76 -0
- data/spec/commands/detailed_command_spec.rb +34 -0
- data/spec/commands/list_command_spec.rb +28 -0
- data/spec/commands/raw_command_spec.rb +69 -0
- data/spec/commands/summary_command_spec.rb +34 -0
- data/spec/commands/totals_command_spec.rb +34 -0
- data/spec/commands/uncovered_command_spec.rb +55 -0
- data/spec/commands/validate_command_spec.rb +213 -0
- data/spec/commands/version_command_spec.rb +38 -0
- data/spec/constants_spec.rb +61 -0
- data/spec/cov_loupe/formatters/source_formatter_spec.rb +267 -0
- data/spec/cov_loupe/formatters_spec.rb +76 -0
- data/spec/cov_loupe/presenters/base_coverage_presenter_spec.rb +79 -0
- data/spec/cov_loupe_model_spec.rb +454 -0
- data/spec/cov_loupe_module_spec.rb +37 -0
- data/spec/cov_loupe_opts_spec.rb +185 -0
- data/spec/coverage_reporter_spec.rb +102 -0
- data/spec/coverage_table_tool_spec.rb +59 -0
- data/spec/coverage_totals_tool_spec.rb +37 -0
- data/spec/error_handler_spec.rb +197 -0
- data/spec/error_mode_spec.rb +139 -0
- data/spec/errors_edge_cases_spec.rb +312 -0
- data/spec/errors_stale_spec.rb +83 -0
- data/spec/file_based_mcp_tools_spec.rb +99 -0
- data/spec/fixtures/project1/lib/bar.rb +5 -0
- data/spec/fixtures/project1/lib/foo.rb +6 -0
- data/spec/help_tool_spec.rb +26 -0
- data/spec/integration_spec.rb +789 -0
- data/spec/logging_fallback_spec.rb +128 -0
- data/spec/mcp_logging_spec.rb +44 -0
- data/spec/mcp_server_integration_spec.rb +23 -0
- data/spec/mcp_server_spec.rb +106 -0
- data/spec/mode_detector_spec.rb +153 -0
- data/spec/model_error_handling_spec.rb +269 -0
- data/spec/model_staleness_spec.rb +79 -0
- data/spec/option_normalizers_spec.rb +203 -0
- data/spec/option_parsers/env_options_parser_spec.rb +221 -0
- data/spec/option_parsers/error_helper_spec.rb +222 -0
- data/spec/path_relativizer_spec.rb +98 -0
- data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
- data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
- data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
- data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
- data/spec/presenters/project_coverage_presenter_spec.rb +87 -0
- data/spec/presenters/project_totals_presenter_spec.rb +144 -0
- data/spec/resolvers/coverage_line_resolver_spec.rb +282 -0
- data/spec/resolvers/resolver_factory_spec.rb +61 -0
- data/spec/resolvers/resultset_path_resolver_spec.rb +60 -0
- data/spec/resultset_loader_spec.rb +167 -0
- data/spec/shared_examples/README.md +115 -0
- data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
- data/spec/shared_examples/file_based_mcp_tools.rb +179 -0
- data/spec/shared_examples/formatted_command_examples.rb +64 -0
- data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
- data/spec/spec_helper.rb +127 -0
- data/spec/staleness_checker_spec.rb +374 -0
- data/spec/staleness_more_spec.rb +42 -0
- data/spec/support/cli_helpers.rb +22 -0
- data/spec/support/control_flow_helpers.rb +20 -0
- data/spec/support/fake_mcp.rb +40 -0
- data/spec/support/io_helpers.rb +29 -0
- data/spec/support/mcp_helpers.rb +35 -0
- data/spec/support/mcp_runner.rb +66 -0
- data/spec/support/mocking_helpers.rb +30 -0
- data/spec/table_format_spec.rb +70 -0
- data/spec/tools/validate_tool_spec.rb +132 -0
- data/spec/tools_error_handling_spec.rb +130 -0
- data/spec/util_spec.rb +154 -0
- data/spec/version_spec.rb +123 -0
- data/spec/version_tool_spec.rb +141 -0
- 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
|