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,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::Presenters::ProjectCoveragePresenter do
6
+ subject(:presenter) do
7
+ described_class.new(
8
+ model: model,
9
+ sort_order: sort_order,
10
+ check_stale: check_stale,
11
+ tracked_globs: tracked_globs
12
+ )
13
+ end
14
+
15
+ let(:model) { instance_double(CovLoupe::CoverageModel) }
16
+ let(:sort_order) { :ascending }
17
+ let(:check_stale) { true }
18
+ let(:tracked_globs) { ['lib/**/*.rb'] }
19
+ let(:files) do
20
+ [
21
+ {
22
+ 'file' => '/abs/path/lib/foo.rb',
23
+ 'covered' => 5,
24
+ 'total' => 6,
25
+ 'percentage' => 83.33,
26
+ 'stale' => false
27
+ },
28
+ {
29
+ 'file' => '/abs/path/lib/bar.rb',
30
+ 'covered' => 1,
31
+ 'total' => 6,
32
+ 'percentage' => 16.67,
33
+ 'stale' => 'L'
34
+ }
35
+ ]
36
+ end
37
+
38
+
39
+ before do
40
+ allow(model).to receive(:all_files).with(sort_order: sort_order, check_stale: check_stale,
41
+ tracked_globs: tracked_globs).and_return(files)
42
+ allow(model).to receive(:relativize) do |payload|
43
+ relativizer = CovLoupe::PathRelativizer.new(
44
+ root: '/abs/path',
45
+ scalar_keys: %w[file file_path],
46
+ array_keys: %w[newer_files missing_files deleted_files]
47
+ )
48
+ relativizer.relativize(payload)
49
+ end
50
+ end
51
+
52
+ describe '#absolute_payload' do
53
+ it 'returns files and counts with stale metadata' do
54
+ payload = presenter.absolute_payload
55
+
56
+ expect(payload['files']).to eq(files)
57
+ expect(payload['counts']).to eq({ 'total' => 2, 'ok' => 1, 'stale' => 1 })
58
+ end
59
+
60
+ it 'memoizes the computed payload' do
61
+ presenter.absolute_payload
62
+ presenter.absolute_payload
63
+
64
+ expect(model).to have_received(:all_files).once
65
+ end
66
+ end
67
+
68
+ describe '#relativized_payload' do
69
+ it 'relativizes the files list' do
70
+ relativized = presenter.relativized_payload
71
+
72
+ expect(relativized['files'].map { |f| f['file'] }).to eq(['lib/foo.rb', 'lib/bar.rb'])
73
+ end
74
+ end
75
+
76
+ describe '#relative_files' do
77
+ it 'returns the relativized file list' do
78
+ expect(presenter.relative_files.map { |f| f['file'] }).to eq(['lib/foo.rb', 'lib/bar.rb'])
79
+ end
80
+ end
81
+
82
+ describe '#relative_counts' do
83
+ it 'returns the relativized counts hash' do
84
+ expect(presenter.relative_counts).to eq({ 'total' => 2, 'ok' => 1, 'stale' => 1 })
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::Presenters::ProjectTotalsPresenter do
6
+ subject(:presenter) do
7
+ described_class.new(
8
+ model: model,
9
+ check_stale: true,
10
+ tracked_globs: ['lib/**/*.rb']
11
+ )
12
+ end
13
+
14
+ let(:model) { instance_double(CovLoupe::CoverageModel) }
15
+ let(:raw_totals) do
16
+ {
17
+ 'lines' => { 'total' => 100, 'covered' => 80, 'uncovered' => 20 },
18
+ 'percentage' => 80.0,
19
+ 'files' => { 'total' => 10, 'ok' => 9, 'stale' => 1 }
20
+ }
21
+ end
22
+
23
+ before do
24
+ allow(model).to receive(:project_totals)
25
+ .with(tracked_globs: ['lib/**/*.rb'], check_stale: true)
26
+ .and_return(raw_totals)
27
+ allow(model).to receive(:relativize) { |payload| payload }
28
+ end
29
+
30
+ describe '#initialize' do
31
+ it 'stores the model, check_stale, and tracked_globs options' do
32
+ expect(presenter.model).to eq(model)
33
+ expect(presenter.check_stale).to be(true)
34
+ expect(presenter.tracked_globs).to eq(['lib/**/*.rb'])
35
+ end
36
+ end
37
+
38
+ describe '#absolute_payload' do
39
+ it 'returns project totals from the model' do
40
+ result = presenter.absolute_payload
41
+
42
+ expect(result).to include('lines', 'percentage', 'files')
43
+ expect(result['lines']).to include('total' => 100, 'covered' => 80, 'uncovered' => 20)
44
+ expect(result['percentage']).to eq(80.0)
45
+ expect(result['files']).to include('total' => 10, 'ok' => 9, 'stale' => 1)
46
+ end
47
+
48
+ it 'caches the result on subsequent calls' do
49
+ presenter.absolute_payload
50
+ presenter.absolute_payload
51
+
52
+ expect(model).to have_received(:project_totals).once
53
+ end
54
+
55
+ it 'passes tracked_globs to the model' do
56
+ presenter.absolute_payload
57
+
58
+ expect(model).to have_received(:project_totals)
59
+ .with(tracked_globs: ['lib/**/*.rb'], check_stale: true)
60
+ end
61
+ end
62
+
63
+ describe '#relativized_payload' do
64
+ it 'returns the relativized payload from the model' do
65
+ result = presenter.relativized_payload
66
+
67
+ expect(result).to eq(raw_totals)
68
+ end
69
+
70
+ it 'calls relativize on the model' do
71
+ presenter.relativized_payload
72
+
73
+ expect(model).to have_received(:relativize).with(raw_totals)
74
+ end
75
+
76
+ it 'caches the result on subsequent calls' do
77
+ presenter.relativized_payload
78
+ presenter.relativized_payload
79
+
80
+ expect(model).to have_received(:relativize).once
81
+ end
82
+ end
83
+
84
+ context 'with check_stale: false' do
85
+ subject(:presenter) do
86
+ described_class.new(
87
+ model: model,
88
+ check_stale: false,
89
+ tracked_globs: nil
90
+ )
91
+ end
92
+
93
+ before do
94
+ allow(model).to receive(:project_totals)
95
+ .with(tracked_globs: nil, check_stale: false)
96
+ .and_return(raw_totals)
97
+ end
98
+
99
+ it 'passes check_stale: false to the model' do
100
+ presenter.absolute_payload
101
+
102
+ expect(model).to have_received(:project_totals)
103
+ .with(tracked_globs: nil, check_stale: false)
104
+ end
105
+ end
106
+
107
+ context 'with empty tracked_globs' do
108
+ subject(:presenter) do
109
+ described_class.new(
110
+ model: model,
111
+ check_stale: true,
112
+ tracked_globs: []
113
+ )
114
+ end
115
+
116
+ before do
117
+ allow(model).to receive(:project_totals)
118
+ .with(tracked_globs: [], check_stale: true)
119
+ .and_return(raw_totals)
120
+ end
121
+
122
+ it 'passes empty tracked_globs to the model' do
123
+ presenter.absolute_payload
124
+
125
+ expect(model).to have_received(:project_totals)
126
+ .with(tracked_globs: [], check_stale: true)
127
+ end
128
+ end
129
+
130
+ context 'with relativization that transforms data' do
131
+ before do
132
+ allow(model).to receive(:relativize) do |payload|
133
+ # Simulate relativization that might transform file paths in nested data
134
+ payload.merge('transformed' => true)
135
+ end
136
+ end
137
+
138
+ it 'applies the transformation from relativize' do
139
+ result = presenter.relativized_payload
140
+
141
+ expect(result['transformed']).to be(true)
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::Resolvers::CoverageLineResolver do
6
+ describe '#lookup_lines' do
7
+ context 'with direct path matching' do
8
+ it 'returns lines array for exact path match' do
9
+ abs_path = '/project/lib/foo.rb'
10
+ cov_data = {
11
+ abs_path => { 'lines' => [1, 0, nil, 2] }
12
+ }
13
+
14
+ resolver = described_class.new(cov_data)
15
+ lines = resolver.lookup_lines(abs_path)
16
+
17
+ expect(lines).to eq([1, 0, nil, 2])
18
+ end
19
+
20
+ it 'returns lines array when entry has lines directly' do
21
+ path = '/tmp/test.rb'
22
+ cov_data = {
23
+ path => { 'lines' => [1, 1, 1] }
24
+ }
25
+
26
+ resolver = described_class.new(cov_data)
27
+ expect(resolver.lookup_lines(path)).to eq([1, 1, 1])
28
+ end
29
+ end
30
+
31
+ context 'with CWD stripping fallback' do
32
+ it 'finds relative path when absolute path includes CWD' do
33
+ cwd = Dir.pwd
34
+ relative_path = 'lib/bar.rb'
35
+ abs_path = File.join(cwd, relative_path)
36
+ cov_data = {
37
+ relative_path => { 'lines' => [1, 0, 1] }
38
+ }
39
+
40
+ resolver = described_class.new(cov_data)
41
+ lines = resolver.lookup_lines(abs_path)
42
+
43
+ expect(lines).to eq([1, 0, 1])
44
+ end
45
+
46
+ it 'does not match when absolute path does not start with CWD' do
47
+ cov_data = {
48
+ 'lib/baz.rb' => { 'lines' => [1, 1] }
49
+ }
50
+
51
+ resolver = described_class.new(cov_data)
52
+
53
+ expect do
54
+ resolver.lookup_lines('/other/directory/lib/baz.rb')
55
+ end.to raise_error(CovLoupe::FileError, /No coverage entry found/)
56
+ end
57
+ end
58
+
59
+ context 'when handling errors' do
60
+ it 'raises FileError when file is not found in coverage data' do
61
+ cov_data = {
62
+ '/project/lib/foo.rb' => { 'lines' => [1, 0] }
63
+ }
64
+
65
+ resolver = described_class.new(cov_data)
66
+
67
+ expect do
68
+ resolver.lookup_lines('/project/lib/missing.rb')
69
+ end.to raise_error(CovLoupe::FileError, /No coverage entry found/)
70
+ end
71
+
72
+ it 'raises FileError when coverage data is empty' do
73
+ resolver = described_class.new({})
74
+
75
+ expect do
76
+ resolver.lookup_lines('/any/path.rb')
77
+ end.to raise_error(CovLoupe::FileError, /No coverage entry found/)
78
+ end
79
+
80
+ it 'raises FileError when entry exists but has no lines or branches' do
81
+ cov_data = {
82
+ '/project/lib/foo.rb' => { 'other_key' => 'value' }
83
+ }
84
+
85
+ resolver = described_class.new(cov_data)
86
+
87
+ expect do
88
+ resolver.lookup_lines('/project/lib/foo.rb')
89
+ end.to raise_error(CovLoupe::FileError, /No coverage entry found/)
90
+ end
91
+ end
92
+
93
+ context 'with branch-only coverage synthesis' do
94
+ it 'synthesizes line hits when only branch coverage exists' do
95
+ abs_path = '/tmp/branch_only.rb'
96
+ branch_cov = {
97
+ abs_path => {
98
+ 'lines' => nil,
99
+ 'branches' => {
100
+ '[:if, 0, 5, 2, 8, 5]' => {
101
+ '[:then, 1, 6, 4, 6, 15]' => 3,
102
+ '[:else, 2, 7, 4, 7, 15]' => 0
103
+ },
104
+ '[:case, 3, 12, 2, 17, 5]' => {
105
+ '[:when, 4, 13, 4, 13, 14]' => 0,
106
+ '[:when, 5, 14, 4, 14, 14]' => 2,
107
+ '[:else, 6, 16, 4, 16, 12]' => 2
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ resolver = described_class.new(branch_cov)
114
+ lines = resolver.lookup_lines(abs_path)
115
+
116
+ expect(lines[5]).to eq(3) # line 6
117
+ expect(lines[6]).to eq(0) # line 7
118
+ expect(lines[12]).to eq(0) # line 13
119
+ expect(lines[13]).to eq(2) # line 14
120
+ expect(lines[15]).to eq(2) # line 16
121
+ expect(lines.count { |v| !v.nil? }).to eq(5)
122
+ end
123
+
124
+ it 'aggregates hits for multiple branches on the same line' do
125
+ path = '/tmp/duplicated.rb'
126
+ branch_cov = {
127
+ path => {
128
+ 'lines' => nil,
129
+ 'branches' => {
130
+ '[:if, 0, 3, 2, 3, 12]' => {
131
+ '[:then, 1, 3, 2, 3, 12]' => 2,
132
+ '[:else, 2, 3, 2, 3, 12]' => 3
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ resolver = described_class.new(branch_cov)
139
+ lines = resolver.lookup_lines(path)
140
+
141
+ expect(lines[2]).to eq(5) # line 3 with summed hits
142
+ end
143
+
144
+ it 'handles array-style branch metadata' do
145
+ path = '/tmp/array_style.rb'
146
+ cov_data = {
147
+ path => {
148
+ 'lines' => nil,
149
+ 'branches' => {
150
+ [:if, 0, 5, 2, 8, 5] => {
151
+ [:then, 1, 6, 4, 6, 15] => 2
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ resolver = described_class.new(cov_data)
158
+ lines = resolver.lookup_lines(path)
159
+
160
+ expect(lines[5]).to eq(2) # line 6
161
+ end
162
+
163
+ it 'returns nil for entries with empty branches' do
164
+ path = '/tmp/empty_branches.rb'
165
+ cov_data = {
166
+ path => {
167
+ 'lines' => nil,
168
+ 'branches' => {}
169
+ }
170
+ }
171
+
172
+ resolver = described_class.new(cov_data)
173
+
174
+ expect do
175
+ resolver.lookup_lines(path)
176
+ end.to raise_error(CovLoupe::FileError)
177
+ end
178
+
179
+ it 'skips malformed branch entries' do
180
+ path = '/tmp/malformed.rb'
181
+ cov_data = {
182
+ path => {
183
+ 'lines' => nil,
184
+ 'branches' => {
185
+ '[:if, 0, 5, 2, 8, 5]' => {
186
+ '[:then, 1, 6, 4, 6, 15]' => 2
187
+ },
188
+ 'malformed_key' => 'not_a_hash',
189
+ '[:if, 1, 10]' => { # missing elements in tuple
190
+ '[:then]' => 1 # also malformed
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ resolver = described_class.new(cov_data)
197
+ lines = resolver.lookup_lines(path)
198
+
199
+ # Should still get line 6 from the valid branch
200
+ expect(lines[5]).to eq(2)
201
+ end
202
+ end
203
+
204
+ context 'with extract_line_number edge cases' do
205
+ let(:resolver) { described_class.new({}) }
206
+
207
+ it 'extracts line number from array metadata' do
208
+ result = resolver.send(:extract_line_number, [:if, 0, 10, 2, 15, 5])
209
+ expect(result).to eq(10)
210
+ end
211
+
212
+ it 'extracts line number from stringified array metadata' do
213
+ result = resolver.send(:extract_line_number, '[:if, 0, 15, 2, 20, 5]')
214
+ expect(result).to eq(15)
215
+ end
216
+
217
+ it 'returns nil for short array' do
218
+ result = resolver.send(:extract_line_number, [:if, 0])
219
+ expect(result).to be_nil
220
+ end
221
+
222
+ it 'returns nil for short string' do
223
+ result = resolver.send(:extract_line_number, '[:if, 0]')
224
+ expect(result).to be_nil
225
+ end
226
+
227
+ it 'returns nil for non-numeric line element in array' do
228
+ result = resolver.send(:extract_line_number, [:if, 0, 'not_a_number', 2])
229
+ expect(result).to be_nil
230
+ end
231
+
232
+ it 'returns nil for non-numeric line element in string' do
233
+ result = resolver.send(:extract_line_number, '[:if, 0, abc, 2]')
234
+ expect(result).to be_nil
235
+ end
236
+
237
+ it 'handles empty string' do
238
+ result = resolver.send(:extract_line_number, '')
239
+ expect(result).to be_nil
240
+ end
241
+
242
+ it 'handles nil input' do
243
+ result = resolver.send(:extract_line_number, nil)
244
+ expect(result).to be_nil
245
+ end
246
+
247
+ # The rescue block catches ArgumentError/TypeError from malformed metadata
248
+ # that can't be converted to line numbers.
249
+ [ArgumentError, TypeError].each do |error_class|
250
+ it "returns nil when string operations raise #{error_class}" do
251
+ weird_object = Object.new
252
+ allow(weird_object).to receive(:to_s).and_raise(error_class, 'test error')
253
+
254
+ result = resolver.send(:extract_line_number, weird_object)
255
+ expect(result).to be_nil
256
+ end
257
+ end
258
+ end
259
+
260
+ context 'with preference for lines over branches' do
261
+ it 'prefers lines array when both lines and branches exist' do
262
+ path = '/tmp/both.rb'
263
+ cov_data = {
264
+ path => {
265
+ 'lines' => [1, 2, 3],
266
+ 'branches' => {
267
+ '[:if, 0, 100, 2, 105, 5]' => {
268
+ '[:then, 1, 101, 4, 101, 15]' => 99
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ resolver = described_class.new(cov_data)
275
+ lines = resolver.lookup_lines(path)
276
+
277
+ # Should return the lines array, not synthesized branch data
278
+ expect(lines).to eq([1, 2, 3])
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe CovLoupe::Resolvers::ResolverFactory do
7
+ describe '.create_resultset_resolver' do
8
+ it 'uses provided candidates when present' do
9
+ custom_candidates = ['alt/.resultset.json']
10
+ resolver = described_class.create_resultset_resolver(
11
+ root: '/tmp/sample',
12
+ candidates: custom_candidates
13
+ )
14
+
15
+ expect(resolver).to be_a(CovLoupe::Resolvers::ResultsetPathResolver)
16
+ expect(resolver.instance_variable_get(:@root)).to eq('/tmp/sample')
17
+ expect(resolver.instance_variable_get(:@candidates)).to eq(custom_candidates)
18
+ end
19
+
20
+ it 'falls back to default candidates when none provided' do
21
+ resolver = described_class.create_resultset_resolver(root: '/tmp/sample')
22
+
23
+ expect(resolver.instance_variable_get(:@candidates)).to eq(
24
+ CovLoupe::Resolvers::ResultsetPathResolver::DEFAULT_CANDIDATES
25
+ )
26
+ end
27
+ end
28
+
29
+ describe '.create_coverage_resolver' do
30
+ it 'wraps coverage data in a CoverageLineResolver' do
31
+ cov = { '/tmp/foo.rb' => { 'lines' => [1, 0] } }
32
+ resolver = described_class.create_coverage_resolver(cov)
33
+
34
+ expect(resolver).to be_a(CovLoupe::Resolvers::CoverageLineResolver)
35
+ expect(resolver.lookup_lines('/tmp/foo.rb')).to eq([1, 0])
36
+ end
37
+ end
38
+
39
+ describe '.find_resultset' do
40
+ it 'locates default resultset within the provided root' do
41
+ Dir.mktmpdir do |dir|
42
+ resultset_path = File.join(dir, '.resultset.json')
43
+ File.write(resultset_path, '{}')
44
+
45
+ resolved = described_class.find_resultset(dir)
46
+
47
+ expect(resolved).to eq(resultset_path)
48
+ end
49
+ end
50
+ end
51
+
52
+ describe '.lookup_lines' do
53
+ it 'delegates to CoverageLineResolver for lookups' do
54
+ cov = { '/tmp/bar.rb' => { 'lines' => [0, 1] } }
55
+
56
+ expect(
57
+ described_class.lookup_lines(cov, '/tmp/bar.rb')
58
+ ).to eq([0, 1])
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+ require 'fileutils'
6
+
7
+ RSpec.describe CovLoupe::Resolvers::ResultsetPathResolver do
8
+ describe '#find_resultset' do
9
+ let(:root) { Dir.mktmpdir }
10
+ let(:resolver) { described_class.new(root: root) }
11
+
12
+ after do
13
+ FileUtils.remove_entry(root) if root && Dir.exist?(root)
14
+ end
15
+
16
+ it 'raises when a specified resultset file cannot be found' do
17
+ expect do
18
+ resolver.find_resultset(resultset: 'missing.json')
19
+ end.to raise_error(RuntimeError, /Specified resultset not found/)
20
+ end
21
+
22
+ it 'raises when a specified directory does not contain .resultset.json' do
23
+ nested_dir = File.join(root, 'coverage')
24
+ Dir.mkdir(nested_dir)
25
+
26
+ expect do
27
+ resolver.find_resultset(resultset: nested_dir)
28
+ end.to raise_error(RuntimeError, /No .resultset.json found in directory/)
29
+ end
30
+
31
+ it 'returns the resolved path when a valid resultset file is provided' do
32
+ file = File.join(root, 'custom.json')
33
+ File.write(file, '{}')
34
+
35
+ expect(resolver.find_resultset(resultset: file)).to eq(file)
36
+ end
37
+
38
+ it 'raises a helpful error when no fallback candidates are found' do
39
+ expect do
40
+ resolver.find_resultset
41
+ end.to raise_error(RuntimeError, /Could not find .resultset.json/)
42
+ end
43
+
44
+ it 'accepts a resultset path already nested under the provided root without double-prefixing' do
45
+ project_root = (FIXTURES_DIR / 'project1').to_s
46
+ resolver = described_class.new(root: project_root)
47
+
48
+ resolved = resolver.find_resultset(resultset: 'spec/fixtures/project1/coverage')
49
+
50
+ expect(resolved).to eq(File.join(project_root, 'coverage', '.resultset.json'))
51
+ end
52
+
53
+ # In non-strict mode, resolve_candidate returns nil instead of raising
54
+ # when the path doesn't exist, allowing fallback resolution to continue.
55
+ it 'returns nil for non-existent path in non-strict mode' do
56
+ result = resolver.send(:resolve_candidate, '/nonexistent/path.json', strict: false)
57
+ expect(result).to be_nil
58
+ end
59
+ end
60
+ end