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,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::CoverageReporter do
6
+ let(:model) { instance_double(CovLoupe::CoverageModel) }
7
+ # Data is pre-sorted by percentage ascending (as model.all_files returns)
8
+ let(:all_files_data) do
9
+ [
10
+ { 'file' => '/project/lib/zero.rb', 'percentage' => 0.0, 'covered' => 0, 'total' => 10 },
11
+ { 'file' => '/project/lib/low.rb', 'percentage' => 25.0, 'covered' => 5, 'total' => 20 },
12
+ { 'file' => '/project/lib/medium.rb', 'percentage' => 60.0, 'covered' => 12, 'total' => 20 },
13
+ { 'file' => '/project/lib/high.rb', 'percentage' => 95.0, 'covered' => 19, 'total' => 20 }
14
+ ]
15
+ end
16
+
17
+ before do
18
+ allow(model).to receive(:all_files).with(sort_order: :ascending).and_return(all_files_data)
19
+ allow(model).to receive(:relativize) do |files|
20
+ files.map { |f| f.merge('file' => f['file'].sub('/project/', '')) }
21
+ end
22
+ end
23
+
24
+ describe '.report' do
25
+ it 'returns formatted low coverage files string' do
26
+ result = described_class.report(threshold: 80, count: 5, model: model)
27
+
28
+ expect(result).to be_a(String)
29
+ expect(result).to include('Lowest coverage files (< 80%):')
30
+ expect(result).to include('lib/zero.rb')
31
+ end
32
+
33
+ it 'includes files below threshold sorted by coverage ascending' do
34
+ result = described_class.report(threshold: 80, count: 5, model: model)
35
+
36
+ expect(result).to include('lib/zero.rb', 'lib/low.rb', 'lib/medium.rb')
37
+ expect(result).not_to include('lib/high.rb')
38
+ end
39
+
40
+ it 'respects count parameter' do
41
+ result = described_class.report(threshold: 80, count: 2, model: model)
42
+
43
+ expect(result).to include('lib/zero.rb')
44
+ expect(result).to include('lib/low.rb')
45
+ expect(result).not_to include('lib/medium.rb')
46
+ end
47
+
48
+ it 'returns nil when no files below threshold' do
49
+ result = described_class.report(threshold: 0, count: 5, model: model)
50
+
51
+ expect(result).to be_nil
52
+ end
53
+
54
+ it 'uses threshold in header' do
55
+ result = described_class.report(threshold: 90, count: 5, model: model)
56
+
57
+ expect(result).to include('< 90%')
58
+ end
59
+
60
+ it 'uses default threshold of 80' do
61
+ result = described_class.report(count: 5, model: model)
62
+
63
+ expect(result).to include('< 80%')
64
+ expect(result).not_to include('lib/high.rb')
65
+ end
66
+
67
+ it 'uses default count of 5' do
68
+ result = described_class.report(threshold: 100, model: model)
69
+
70
+ # All 4 files are below 100%
71
+ expect(result).to include('lib/zero.rb')
72
+ expect(result).to include('lib/high.rb')
73
+ end
74
+
75
+ it 'relativizes file paths' do
76
+ result = described_class.report(threshold: 80, count: 5, model: model)
77
+
78
+ expect(result).to include('lib/zero.rb')
79
+ expect(result).not_to include('/project/')
80
+ end
81
+
82
+ it 'aligns percentages correctly' do
83
+ result = described_class.report(threshold: 100, count: 5, model: model)
84
+ lines = result.split("\n")
85
+
86
+ # lines[0] is empty (leading newline), lines[1] is header, lines[2..] are data
87
+ expect(lines[2]).to match(/^\s+0\.0%/)
88
+ expect(lines[3]).to match(/^\s+25\.0%/)
89
+ end
90
+ end
91
+
92
+ describe 'module_function behavior' do
93
+ it 'report is available as a module method' do
94
+ expect(described_class).to respond_to(:report)
95
+ end
96
+
97
+ it 'report is available as a private instance method when included' do
98
+ klass = Class.new { include CovLoupe::CoverageReporter }
99
+ expect(klass.private_instance_methods).to include(:report)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'cov_loupe/tools/coverage_table_tool'
5
+
6
+ RSpec.describe CovLoupe::Tools::CoverageTableTool do
7
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
8
+ let(:server_context) { instance_double('ServerContext').as_null_object }
9
+
10
+ before do
11
+ setup_mcp_response_stub
12
+ end
13
+
14
+ def run_tool(staleness: :off)
15
+ # Let real CoverageModel work to test actual format_table behavior
16
+ described_class.call(root: root, staleness: staleness,
17
+ server_context: server_context).payload.first['text']
18
+ end
19
+
20
+ it 'returns a formatted table as a string' do
21
+ output = run_tool
22
+
23
+ # Contains table structure, headers, and file data
24
+ expect(output).to include(
25
+ '┌', '┬', '┐', '│', '├', '┼', '┤', '└', '┴', '┘',
26
+ 'File', 'Covered', 'Total', ' │ Stale │',
27
+ 'lib/foo.rb', 'lib/bar.rb',
28
+ 'Files: total 2, ok 0, stale 2'
29
+ )
30
+ end
31
+
32
+ it 'configures CLI to enforce stale checking when requested' do
33
+ model = instance_double(CovLoupe::CoverageModel,
34
+ all_files: [
35
+ { 'file' => "#{root}/lib/foo.rb", 'percentage' => 100.0, 'covered' => 10, 'total' => 10,
36
+ 'stale' => false }
37
+ ],
38
+ relativize: ->(payload) { payload },
39
+ format_table: 'Mock table output'
40
+ )
41
+ allow(CovLoupe::CoverageModel).to receive(:new).with(
42
+ root: root,
43
+ resultset: nil,
44
+ staleness: :error,
45
+ tracked_globs: nil
46
+ ).and_return(model)
47
+ allow(model).to receive(:format_table).and_return('Mock table output')
48
+
49
+ described_class.call(root: root, staleness: :error, server_context: server_context)
50
+
51
+ expect(CovLoupe::CoverageModel).to have_received(:new).with(
52
+ root: root,
53
+ resultset: nil,
54
+ staleness: :error,
55
+ tracked_globs: nil
56
+ )
57
+ expect(model).to have_received(:format_table)
58
+ end
59
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'cov_loupe/tools/coverage_totals_tool'
5
+
6
+ RSpec.describe CovLoupe::Tools::CoverageTotalsTool do
7
+ subject(:tool_response) { described_class.call(root: root, server_context: server_context) }
8
+
9
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
10
+ let(:server_context) { instance_double('ServerContext').as_null_object }
11
+
12
+ before do
13
+ setup_mcp_response_stub
14
+ model = instance_double(CovLoupe::CoverageModel)
15
+ allow(CovLoupe::CoverageModel).to receive(:new).and_return(model)
16
+
17
+ payload = {
18
+ 'lines' => { 'total' => 42, 'covered' => 40, 'uncovered' => 2 },
19
+ 'percentage' => 95.24,
20
+ 'files' => { 'total' => 4, 'ok' => 4, 'stale' => 0 }
21
+ }
22
+
23
+ presenter = instance_double(CovLoupe::Presenters::ProjectTotalsPresenter)
24
+ allow(CovLoupe::Presenters::ProjectTotalsPresenter).to receive(:new).and_return(presenter)
25
+ allow(presenter).to receive(:relativized_payload).and_return(payload)
26
+ end
27
+
28
+ it_behaves_like 'an MCP tool that returns text JSON'
29
+
30
+ it 'returns aggregated totals' do
31
+ data, = expect_mcp_text_json(tool_response, expected_keys: ['lines', 'percentage', 'files'])
32
+
33
+ expect(data['lines']).to include('total' => 42, 'covered' => 40, 'uncovered' => 2)
34
+ expect(data['files']).to include('total' => 4, 'stale' => 0)
35
+ expect(data['percentage']).to eq(95.24)
36
+ end
37
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::ErrorHandler do
6
+ subject(:handler) { described_class.new(error_mode: :log, logger: logger) }
7
+
8
+ let(:logger) do
9
+ Class.new do
10
+ attr_reader :messages
11
+
12
+ def initialize = @messages = []
13
+ def error(msg) = @messages << msg
14
+ end.new
15
+ end
16
+
17
+
18
+ it 'maps filesystem errors to friendly custom errors' do
19
+ e = handler.convert_standard_error(Errno::EISDIR.new('Is a directory @ rb_sysopen - a_dir'))
20
+ expect(e).to be_a(CovLoupe::NotAFileError)
21
+
22
+ e = handler.convert_standard_error(
23
+ Errno::ENOENT.new('No such file or directory @ rb_sysopen - missing.txt')
24
+ )
25
+ expect(e).to be_a(CovLoupe::FileNotFoundError)
26
+
27
+ e = handler.convert_standard_error(Errno::EACCES.new('Permission denied @ rb_sysopen - secret'))
28
+ expect(e).to be_a(CovLoupe::FilePermissionError)
29
+ end
30
+
31
+ it 'maps JSON::ParserError to CoverageDataError' do
32
+ e = handler.convert_standard_error(JSON::ParserError.new('unexpected token'))
33
+ expect(e).to be_a(CovLoupe::CoverageDataError)
34
+ expect(e.user_friendly_message).to include('Invalid coverage data format')
35
+ end
36
+
37
+ it 'maps ArgumentError by message' do
38
+ e = handler.convert_standard_error(
39
+ ArgumentError.new('wrong number of arguments (given 1, expected 2)')
40
+ )
41
+ expect(e).to be_a(CovLoupe::UsageError)
42
+
43
+ e = handler.convert_standard_error(ArgumentError.new('invalid option'))
44
+ expect(e).to be_a(CovLoupe::ConfigurationError)
45
+ end
46
+
47
+ it 'maps NoMethodError to CoverageDataError with helpful info' do
48
+ e = handler.convert_standard_error(
49
+ NoMethodError.new("undefined method `fetch' for #<Hash:0x123>")
50
+ )
51
+ expect(e).to be_a(CovLoupe::CoverageDataError)
52
+ expect(e.user_friendly_message).to include('Invalid coverage data structure')
53
+ end
54
+
55
+ it 'maps runtime strings from util to friendly errors' do
56
+ e = handler.convert_standard_error(
57
+ RuntimeError.new('Could not find .resultset.json under /path; run tests')
58
+ )
59
+ expect(e).to be_a(CovLoupe::CoverageDataError)
60
+ expect(e.user_friendly_message).to include('run your tests first')
61
+
62
+ e = handler.convert_standard_error(
63
+ RuntimeError.new('No .resultset.json found in directory: /path')
64
+ )
65
+ expect(e).to be_a(CovLoupe::CoverageDataError)
66
+
67
+ e = handler.convert_standard_error(
68
+ RuntimeError.new('Specified resultset not found: /nowhere/file.json')
69
+ )
70
+ expect(e).to be_a(CovLoupe::ResultsetNotFoundError)
71
+ end
72
+
73
+ it 'logs via provided logger' do
74
+ begin
75
+ handler.handle_error(Errno::ENOENT.new('No such file or directory @ rb_sysopen - x'),
76
+ context: 'test', reraise: false)
77
+ rescue
78
+ # reraise disabled
79
+ end
80
+ expect(logger.messages.join).to include('Error in test')
81
+ end
82
+
83
+ it 'converts TypeError to CoverageDataError for invalid data structures' do
84
+ error = TypeError.new('wrong argument type')
85
+ result = handler.convert_standard_error(error)
86
+
87
+ expect(result).to be_a(CovLoupe::CoverageDataError)
88
+ expect(result.user_friendly_message).to include('Invalid coverage data structure')
89
+ end
90
+
91
+ it 'returns generic Error for unrecognized SystemCallError' do
92
+ error = Errno::EEXIST.new('File exists')
93
+ result = handler.convert_standard_error(error)
94
+
95
+ expect(result).to be_a(CovLoupe::Error)
96
+ expect(result.user_friendly_message).to include('An unexpected error occurred')
97
+ end
98
+
99
+ it 'handles NoMethodError with non-standard message format' do
100
+ error = NoMethodError.new('some weird error message without the expected pattern')
101
+ result = handler.convert_standard_error(error)
102
+
103
+ expect(result).to be_a(CovLoupe::CoverageDataError)
104
+ expect(result.user_friendly_message).to include('some weird error message')
105
+ end
106
+
107
+ describe 'else branch for non-StandardError exceptions' do
108
+ # This tests the else clause in convert_standard_error for exceptions
109
+ # that don't inherit from StandardError
110
+ it 'returns generic Error for Exception subclasses not inheriting from StandardError' do
111
+ # Create a custom exception that inherits from Exception, not StandardError
112
+ custom_exception_class = Class.new(StandardError) do
113
+ def message
114
+ 'Custom non-standard exception'
115
+ end
116
+ end
117
+
118
+ error = custom_exception_class.new
119
+ result = handler.convert_standard_error(error)
120
+
121
+ expect(result).to be_a(CovLoupe::Error)
122
+ expect(result.user_friendly_message).to include('An unexpected error occurred')
123
+ expect(result.user_friendly_message).to include('Custom non-standard exception')
124
+ end
125
+
126
+ it 'returns generic Error for ScriptError subclasses' do
127
+ # ScriptError inherits from Exception, not StandardError
128
+ error = NotImplementedError.new('This feature is not implemented')
129
+ result = handler.convert_standard_error(error)
130
+
131
+ expect(result).to be_a(CovLoupe::Error)
132
+ expect(result.user_friendly_message).to include('An unexpected error occurred')
133
+ end
134
+ end
135
+
136
+ describe 'extract_method_info fallback' do
137
+ # This tests the fallback path in extract_method_info when NoMethodError
138
+ # message doesn't match the expected pattern
139
+ it 'returns original message when pattern does not match' do
140
+ # Test various NoMethodError formats that won't match the regex
141
+ test_messages = [
142
+ 'method not found',
143
+ 'private method called',
144
+ 'undefined local variable or method',
145
+ ''
146
+ ]
147
+
148
+ test_messages.each do |msg|
149
+ error = NoMethodError.new(msg)
150
+ result = handler.convert_standard_error(error)
151
+
152
+ expect(result).to be_a(CovLoupe::CoverageDataError)
153
+ # The original message should be preserved
154
+ expect(result.message).to include(msg) unless msg.empty?
155
+ end
156
+ end
157
+ end
158
+
159
+ # ErrorHandler#convert_runtime_error handles RuntimeErrors differently based on context:
160
+ # - :coverage_loading assumes errors relate to coverage data and maps them to
161
+ # CoverageDataError or ResultsetNotFoundError
162
+ # - :general (or any other context) maps unrecognized errors to generic Error
163
+ # This tests the final else branch in convert_runtime_error.
164
+ describe 'convert_runtime_error with general context' do
165
+ it 'converts RuntimeError with unrecognized message to generic Error' do
166
+ error = RuntimeError.new('Some completely unexpected runtime error')
167
+
168
+ result = handler.convert_standard_error(error, context: :general)
169
+
170
+ expect(result).to be_a(CovLoupe::Error)
171
+ expect(result.user_friendly_message)
172
+ .to include('An unexpected error occurred', 'unexpected runtime error')
173
+ end
174
+ end
175
+
176
+ describe '#handle_error with reraise' do
177
+ it 're-raises CovLoupe::Error when reraise is true' do
178
+ error = CovLoupe::FileNotFoundError.new('Test file not found')
179
+
180
+ expect { handler.handle_error(error, context: 'test', reraise: true) }
181
+ .to raise_error(CovLoupe::FileNotFoundError, 'Test file not found')
182
+
183
+ # Verify it was logged
184
+ expect(logger.messages.join).to include('Error in test')
185
+ end
186
+
187
+ it 'converts and re-raises StandardError when reraise is true' do
188
+ error = Errno::ENOENT.new('No such file or directory @ rb_sysopen - missing.rb')
189
+
190
+ expect { handler.handle_error(error, context: 'test', reraise: true) }
191
+ .to raise_error(CovLoupe::FileNotFoundError)
192
+
193
+ # Verify it was logged
194
+ expect(logger.messages.join).to include('Error in test')
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'Error Mode System' do
6
+ let(:test_logger) do
7
+ Class.new do
8
+ attr_reader :messages
9
+
10
+ def initialize = @messages = []
11
+ def error(msg) = @messages << msg
12
+ end.new
13
+ end
14
+
15
+ let(:test_error) { StandardError.new('Test error message') }
16
+
17
+ describe 'ErrorHandler error modes' do
18
+ context 'with error_mode: :off' do
19
+ subject(:handler) { CovLoupe::ErrorHandler.new(error_mode: :off, logger: test_logger) }
20
+
21
+ it 'does not log errors' do
22
+ expect(handler.log_errors?).to be false
23
+ expect(handler.show_stack_traces?).to be false
24
+
25
+ handler.handle_error(test_error, context: 'test', reraise: false)
26
+ expect(test_logger.messages).to be_empty
27
+ end
28
+ end
29
+
30
+ context 'with error_mode: :log' do
31
+ subject(:handler) { CovLoupe::ErrorHandler.new(error_mode: :log, logger: test_logger) }
32
+
33
+ it 'logs errors but not stack traces' do
34
+ expect(handler.log_errors?).to be true
35
+ expect(handler.show_stack_traces?).to be false
36
+
37
+ handler.handle_error(test_error, context: 'test', reraise: false)
38
+ logged_message = test_logger.messages.join
39
+ expect(logged_message).to include('Error in test: StandardError: Test error message')
40
+ expect(logged_message).not_to include('spec/error_mode_spec.rb') # No stack trace
41
+ end
42
+ end
43
+
44
+ context 'with error_mode: :debug' do
45
+ subject(:handler) { CovLoupe::ErrorHandler.new(error_mode: :debug, logger: test_logger) }
46
+
47
+ it 'logs errors with stack traces' do
48
+ expect(handler.log_errors?).to be true
49
+ expect(handler.show_stack_traces?).to be true
50
+
51
+ # Create an error with a proper backtrace
52
+ begin
53
+ raise StandardError, 'Test error message'
54
+ rescue => e
55
+ handler.handle_error(e, context: 'test', reraise: false)
56
+ end
57
+
58
+ logged_message = test_logger.messages.join
59
+ expect(logged_message).to include('Error in test: StandardError: Test error message')
60
+ expect(logged_message).to include('spec/error_mode_spec.rb') # Stack trace included
61
+ end
62
+ end
63
+ end
64
+
65
+ describe 'ErrorHandlerFactory' do
66
+ it 'creates handlers with correct modes' do
67
+ cli_handler = CovLoupe::ErrorHandlerFactory.for_cli(error_mode: :debug)
68
+ expect(cli_handler.error_mode).to eq(:debug)
69
+
70
+ lib_handler = CovLoupe::ErrorHandlerFactory.for_library(error_mode: :off)
71
+ expect(lib_handler.error_mode).to eq(:off)
72
+
73
+ mcp_handler = CovLoupe::ErrorHandlerFactory.for_mcp_server(error_mode: :log)
74
+ expect(mcp_handler.error_mode).to eq(:log)
75
+ end
76
+ end
77
+
78
+ describe 'MCP Tools error mode support' do
79
+ before { setup_mcp_response_stub }
80
+
81
+ it 'BaseTool.handle_mcp_error respects error modes' do
82
+ test_error = StandardError.new('Test MCP error')
83
+
84
+ # Test different error modes
85
+ [:off, :log, :debug].each do |mode|
86
+ expect(CovLoupe::ErrorHandlerFactory)
87
+ .to receive(:for_mcp_server).with(error_mode: mode).and_call_original
88
+
89
+ response = CovLoupe::BaseTool.handle_mcp_error(test_error, 'TestTool', error_mode: mode)
90
+ expect(response).to be_a(MCP::Tool::Response)
91
+ expect(response.payload.first['text']).to include('Error:')
92
+ end
93
+ end
94
+ end
95
+
96
+ describe 'CLI error mode support' do
97
+ let(:project_dir) { File.join(__dir__, 'fixtures', 'project1') }
98
+
99
+ it 'accepts --error-mode flag' do
100
+ cli = CovLoupe::CoverageCLI.new
101
+
102
+ # Test that the option parser accepts the flag
103
+ expect do
104
+ cli.send(:parse_options!, ['--error-mode', 'debug', 'summary', 'lib/foo.rb'])
105
+ end.not_to raise_error
106
+
107
+ expect(cli.config.error_mode).to eq(:debug)
108
+ end
109
+
110
+ it 'creates error handler with specified mode' do
111
+ cli = CovLoupe::CoverageCLI.new
112
+ cli.send(:parse_options!, ['--error-mode', 'off', 'summary', 'lib/foo.rb'])
113
+
114
+ expect(cli.send(:error_handler).error_mode).to eq(:off)
115
+ end
116
+
117
+ it 'validates error mode values' do
118
+ cli = CovLoupe::CoverageCLI.new
119
+
120
+ expect do
121
+ cli.send(:parse_options!, ['--error-mode', 'invalid', 'summary', 'lib/foo.rb'])
122
+ end.to raise_error(OptionParser::InvalidArgument)
123
+ end
124
+ end
125
+
126
+ describe 'Error mode validation' do
127
+ it 'raises ArgumentError for invalid error modes' do
128
+ expect do
129
+ CovLoupe::ErrorHandler.new(error_mode: :invalid)
130
+ end.to raise_error(ArgumentError, /Invalid error_mode: :invalid/)
131
+ end
132
+
133
+ it 'accepts all valid error modes' do
134
+ expect { CovLoupe::ErrorHandler.new(error_mode: :off) }.not_to raise_error
135
+ expect { CovLoupe::ErrorHandler.new(error_mode: :log) }.not_to raise_error
136
+ expect { CovLoupe::ErrorHandler.new(error_mode: :debug) }.not_to raise_error
137
+ end
138
+ end
139
+ end