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,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require_relative '../option_normalizers'
5
+
6
+ module CovLoupe
7
+ module OptionParsers
8
+ class EnvOptionsParser
9
+ ENV_VAR = 'COV_LOUPE_OPTS'
10
+
11
+ def initialize(env_var: ENV_VAR)
12
+ @env_var = env_var
13
+ end
14
+
15
+ def parse_env_opts
16
+ opts_string = ENV[@env_var]
17
+ return [] unless opts_string && !opts_string.empty?
18
+
19
+ begin
20
+ Shellwords.split(opts_string)
21
+ rescue ArgumentError => e
22
+ raise CovLoupe::ConfigurationError, "Invalid #{@env_var} format: #{e.message}"
23
+ end
24
+ end
25
+
26
+ def pre_scan_error_mode(argv, error_mode_normalizer: method(:normalize_error_mode))
27
+ # Quick scan for --error-mode to ensure early errors are logged correctly
28
+ argv.each_with_index do |arg, i|
29
+ if arg == '--error-mode' && argv[i + 1]
30
+ return error_mode_normalizer.call(argv[i + 1])
31
+ elsif arg.start_with?('--error-mode=')
32
+ value = arg.split('=', 2)[1]
33
+ return nil if value.to_s.empty?
34
+ return error_mode_normalizer.call(value) if value
35
+ end
36
+ end
37
+ nil
38
+ rescue
39
+ # Ignore errors during pre-scan; they'll be caught during actual parsing
40
+ nil
41
+ end
42
+
43
+ private def normalize_error_mode(value)
44
+ OptionNormalizers.normalize_error_mode(value, strict: false, default: :log)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CovLoupe
4
+ module OptionParsers
5
+ class ErrorHelper
6
+ SUBCOMMANDS = %w[list summary raw uncovered detailed totals version].freeze
7
+
8
+ def initialize(subcommands = SUBCOMMANDS)
9
+ @subcommands = subcommands
10
+ end
11
+
12
+ def handle_option_parser_error(error, argv: [], usage_hint: "Run '#{program_name} --help' for usage information.")
13
+ message = error.message.to_s
14
+ # Suggest a subcommand when an invalid option matches a known subcommand
15
+ option = extract_invalid_option(message)
16
+
17
+ if option&.start_with?('--') && @subcommands.include?(option[2..])
18
+ suggest_subcommand(option)
19
+ else
20
+ # Generic message from OptionParser
21
+ warn "Error: #{message}"
22
+ # Attempt to derive a helpful hint for enumerated options
23
+ if (hint = build_enum_value_hint(argv))
24
+ warn hint
25
+ end
26
+ end
27
+ warn usage_hint
28
+ exit 1
29
+ end
30
+
31
+ private def extract_invalid_option(message)
32
+ message.match(/invalid option: (.+)/)[1]
33
+ rescue
34
+ nil
35
+ end
36
+
37
+ private def suggest_subcommand(option)
38
+ subcommand = option[2..]
39
+ warn "Error: '#{option}' is not a valid option. Did you mean the '#{subcommand}' subcommand?"
40
+ warn "Try: #{program_name} #{subcommand} [args]"
41
+ end
42
+
43
+ private def build_enum_value_hint(argv)
44
+ rules = enumerated_option_rules
45
+ tokens = Array(argv)
46
+ rules.each do |rule|
47
+ hint = build_hint_for_rule(rule, tokens)
48
+ return hint if hint
49
+ end
50
+ nil
51
+ end
52
+
53
+ private def build_hint_for_rule(rule, tokens)
54
+ switches = rule[:switches]
55
+ allowed = rule[:values]
56
+ display = rule[:display] || allowed.join(', ')
57
+ preferred = switches.find { |s| s.start_with?('--') } || switches.first
58
+
59
+ tokens.each_with_index do |tok, i|
60
+ # --opt=value form
61
+ if equal_form_match?(tok, switches, preferred)
62
+ hint = handle_equal_form(tok, switches, preferred, display, allowed)
63
+ return hint if hint
64
+ end
65
+
66
+ # --opt value or -o value form
67
+ if switches.include?(tok)
68
+ hint = handle_space_form(tokens, i, preferred, display, allowed)
69
+ return hint if hint
70
+ end
71
+ end
72
+ nil
73
+ end
74
+
75
+ private def equal_form_match?(token, switches, preferred)
76
+ token.start_with?(preferred + '=') || switches.any? { |s| token.start_with?(s + '=') }
77
+ end
78
+
79
+ private def handle_equal_form(token, switches, preferred, display, allowed)
80
+ sw = switches.find { |s| token.start_with?(s + '=') } || preferred
81
+ val = token.split('=', 2)[1]
82
+ "Valid values for #{sw}: #{display}" if val && !allowed.include?(val)
83
+ end
84
+
85
+ private def handle_space_form(tokens, index, preferred, display, allowed)
86
+ val = tokens[index + 1]
87
+ # If missing value, provide hint; if present and invalid, also hint
88
+ if val.nil? || val.start_with?('-') || !allowed.include?(val)
89
+ "Valid values for #{preferred}: #{display}"
90
+ end
91
+ end
92
+
93
+ private def enumerated_option_rules
94
+ [
95
+ { switches: ['-S', '--staleness'], values: %w[off o error e], display: 'o[ff]|e[rror]' },
96
+ { switches: ['-s', '--source'], values: %w[full f uncovered u],
97
+ display: 'f[ull]|u[ncovered]' },
98
+ { switches: ['--error-mode'], values: %w[off o log l debug d],
99
+ display: 'o[ff]|l[og]|d[ebug]' },
100
+ { switches: ['-o', '--sort-order'], values: %w[a d ascending descending],
101
+ display: 'a[scending]|d[escending]' }
102
+ ]
103
+ end
104
+
105
+ private def program_name
106
+ 'cov-loupe'
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module CovLoupe
6
+ # Utility object that converts configured path-bearing keys to forms
7
+ # relative to the project root while leaving the original payload untouched.
8
+ class PathRelativizer
9
+ def initialize(root:, scalar_keys:, array_keys: [])
10
+ @root = Pathname.new(File.absolute_path(root || '.'))
11
+ @scalar_keys = Array(scalar_keys).map(&:to_s).freeze
12
+ @array_keys = Array(array_keys).map(&:to_s).freeze
13
+ end
14
+
15
+ def relativize(obj)
16
+ deep_copy_and_relativize(obj)
17
+ end
18
+
19
+ # Converts an absolute path to a path relative to the root.
20
+ # Falls back to the original path if conversion fails (e.g., different drive on Windows).
21
+ #
22
+ # @param path [String] file path (absolute or relative)
23
+ # @return [String] relative path or original path on failure
24
+ def relativize_path(path)
25
+ root_str = @root.to_s
26
+ abs = File.absolute_path(path, root_str)
27
+ return path unless abs.start_with?(root_prefix(root_str)) || abs == root_str
28
+
29
+ Pathname.new(abs).relative_path_from(@root).to_s
30
+ rescue ArgumentError
31
+ path
32
+ end
33
+
34
+ private def deep_copy_and_relativize(obj)
35
+ case obj
36
+ when Hash
37
+ obj.each_with_object({}) do |(k, v), acc|
38
+ acc[k] = relativize_value(k, v)
39
+ end
40
+ when Array
41
+ obj.map { |item| deep_copy_and_relativize(item) }
42
+ else
43
+ obj
44
+ end
45
+ end
46
+
47
+ private def relativize_value(key, value)
48
+ key_str = key.to_s
49
+ if @scalar_keys.include?(key_str) && value.is_a?(String)
50
+ relativize_path(value)
51
+ elsif @array_keys.include?(key_str) && value.is_a?(Array)
52
+ value.map do |item|
53
+ item.is_a?(String) ? relativize_path(item) : deep_copy_and_relativize(item)
54
+ end
55
+ else
56
+ deep_copy_and_relativize(value)
57
+ end
58
+ end
59
+
60
+ private def root_prefix(root_str)
61
+ root_str.end_with?(File::SEPARATOR) ? root_str : root_str + File::SEPARATOR
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CovLoupe
4
+ # Evaluates coverage predicates from either Ruby code strings or files.
5
+ # Used by the validate subcommand, validate MCP tool, and library API.
6
+ #
7
+ # Security Warning:
8
+ # Predicates execute as arbitrary Ruby code with full system privileges.
9
+ # Only use predicates from trusted sources.
10
+ class PredicateEvaluator
11
+ # Evaluate a predicate from a code string
12
+ #
13
+ # @param code [String] Ruby code that returns a callable (lambda, proc, or object with #call)
14
+ # @param model [CoverageModel] The coverage model to pass to the predicate
15
+ # @return [Boolean] The result of calling the predicate with the model
16
+ # @raise [RuntimeError] If the code doesn't return a callable or has syntax errors
17
+ def self.evaluate_code(code, model)
18
+ # WARNING: The predicate code executes with full Ruby privileges.
19
+ # It has unrestricted access to the file system, network, and system commands.
20
+ # Only use predicate code from trusted sources.
21
+ #
22
+ # We evaluate in a fresh Object context to prevent accidental access to
23
+ # internals, but this provides NO security isolation.
24
+ evaluation_context = Object.new
25
+ predicate = evaluation_context.instance_eval(code, '<predicate>', 1)
26
+
27
+ validate_callable(predicate)
28
+ predicate.call(model)
29
+ rescue SyntaxError => e
30
+ raise "Syntax error in predicate code: #{e.message}"
31
+ end
32
+
33
+ # Evaluate a predicate from a file
34
+ #
35
+ # @param path [String] Path to Ruby file containing predicate code
36
+ # @param model [CoverageModel] The coverage model to pass to the predicate
37
+ # @return [Boolean] The result of calling the predicate with the model
38
+ # @raise [RuntimeError] If the file doesn't exist, doesn't return a callable, or has syntax errors
39
+ def self.evaluate_file(path, model)
40
+ unless File.exist?(path)
41
+ raise "Predicate file not found: #{path}"
42
+ end
43
+
44
+ content = File.read(path)
45
+
46
+ # WARNING: The predicate code executes with full Ruby privileges.
47
+ # It has unrestricted access to the file system, network, and system commands.
48
+ # Only use predicate files from trusted sources.
49
+ #
50
+ # We evaluate in a fresh Object context to prevent accidental access to
51
+ # internals, but this provides NO security isolation.
52
+ evaluation_context = Object.new
53
+ predicate = evaluation_context.instance_eval(content, path, 1)
54
+
55
+ validate_callable(predicate)
56
+ predicate.call(model)
57
+ rescue SyntaxError => e
58
+ raise "Syntax error in predicate file: #{e.message}"
59
+ end
60
+
61
+ # Validate that an object is callable
62
+ #
63
+ # @param predicate [Object] The object to check
64
+ # @raise [RuntimeError] If the object doesn't respond to #call
65
+ def self.validate_callable(predicate)
66
+ unless predicate.respond_to?(:call)
67
+ raise 'Predicate must be callable (lambda, proc, or object with #call method)'
68
+ end
69
+ end
70
+ private_class_method :validate_callable
71
+ end
72
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CovLoupe
4
+ module Presenters
5
+ # Shared presenter behavior for single-file coverage payloads.
6
+ class BaseCoveragePresenter
7
+ attr_reader :model, :path
8
+
9
+ def initialize(model:, path:)
10
+ @model = model
11
+ @path = path
12
+ end
13
+
14
+ # Returns the absolute-path payload augmented with stale metadata.
15
+ def absolute_payload
16
+ @absolute_payload ||= begin
17
+ payload = build_payload
18
+ payload.merge('stale' => model.staleness_for(path))
19
+ end
20
+ end
21
+
22
+ # Returns the payload with file paths relativized for presentation.
23
+ def relativized_payload
24
+ @relativized_payload ||= model.relativize(absolute_payload)
25
+ end
26
+
27
+ # Returns the cached stale status for the file.
28
+ def stale
29
+ absolute_payload['stale']
30
+ end
31
+
32
+ # Returns the relativized file path used in CLI output.
33
+ def relative_path
34
+ relativized_payload['file']
35
+ end
36
+
37
+ private def build_payload
38
+ raise NotImplementedError, "#{self.class} must implement #build_payload"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_coverage_presenter'
4
+
5
+ module CovLoupe
6
+ module Presenters
7
+ # Provides shared detailed coverage payloads for CLI and MCP callers.
8
+ class CoverageDetailedPresenter < BaseCoveragePresenter
9
+ private def build_payload
10
+ model.detailed_for(path)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_coverage_presenter'
4
+
5
+ module CovLoupe
6
+ module Presenters
7
+ # Provides shared raw coverage payloads for CLI and MCP callers.
8
+ class CoverageRawPresenter < BaseCoveragePresenter
9
+ private def build_payload
10
+ model.raw_for(path)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_coverage_presenter'
4
+
5
+ module CovLoupe
6
+ module Presenters
7
+ # Builds a consistent summary payload that both the CLI and MCP surfaces can use.
8
+ class CoverageSummaryPresenter < BaseCoveragePresenter
9
+ private def build_payload
10
+ model.summary_for(path)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_coverage_presenter'
4
+
5
+ module CovLoupe
6
+ module Presenters
7
+ # Provides shared uncovered coverage payloads for CLI and MCP callers.
8
+ class CoverageUncoveredPresenter < BaseCoveragePresenter
9
+ private def build_payload
10
+ model.uncovered_for(path)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CovLoupe
4
+ module Presenters
5
+ # Provides repository-wide coverage summaries shared by CLI and MCP surfaces.
6
+ class ProjectCoveragePresenter
7
+ attr_reader :model, :sort_order, :check_stale, :tracked_globs
8
+
9
+ def initialize(model:, sort_order:, check_stale:, tracked_globs:)
10
+ @model = model
11
+ @sort_order = sort_order
12
+ @check_stale = check_stale
13
+ @tracked_globs = tracked_globs
14
+ end
15
+
16
+ # Returns the absolute-path payload including counts.
17
+ def absolute_payload
18
+ @absolute_payload ||= begin
19
+ files = model.all_files(
20
+ sort_order: sort_order,
21
+ check_stale: check_stale,
22
+ tracked_globs: tracked_globs
23
+ )
24
+ { 'files' => files, 'counts' => build_counts(files) }
25
+ end
26
+ end
27
+
28
+ # Returns the payload with file paths relativized for presentation.
29
+ def relativized_payload
30
+ @relativized_payload ||= model.relativize(absolute_payload)
31
+ end
32
+
33
+ # Returns the relativized file rows.
34
+ def relative_files
35
+ relativized_payload['files']
36
+ end
37
+
38
+ # Returns the coverage counts with relative file paths.
39
+ def relative_counts
40
+ relativized_payload['counts']
41
+ end
42
+
43
+ private def build_counts(files)
44
+ total = files.length
45
+ stale = files.count { |f| f['stale'] }
46
+ { 'total' => total, 'ok' => total - stale, 'stale' => stale }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CovLoupe
4
+ module Presenters
5
+ # Provides aggregated line totals and average coverage across the project.
6
+ class ProjectTotalsPresenter
7
+ attr_reader :model, :check_stale, :tracked_globs
8
+
9
+ def initialize(model:, check_stale:, tracked_globs:)
10
+ @model = model
11
+ @check_stale = check_stale
12
+ @tracked_globs = tracked_globs
13
+ end
14
+
15
+ def absolute_payload
16
+ @absolute_payload ||= model.project_totals(
17
+ tracked_globs: tracked_globs,
18
+ check_stale: check_stale
19
+ )
20
+ end
21
+
22
+ def relativized_payload
23
+ @relativized_payload ||= model.relativize(absolute_payload)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CovLoupe
4
+ module Resolvers
5
+ class CoverageLineResolver
6
+ def initialize(cov_data)
7
+ @cov_data = cov_data
8
+ end
9
+
10
+ def lookup_lines(file_abs)
11
+ # First try exact match
12
+ direct_match = find_direct_match(file_abs)
13
+ return direct_match if direct_match
14
+
15
+ # Then try without current working directory prefix
16
+ stripped_match = find_stripped_match(file_abs)
17
+ return stripped_match if stripped_match
18
+
19
+ raise_not_found_error(file_abs)
20
+ end
21
+
22
+ attr_reader :cov_data
23
+
24
+ private def find_direct_match(file_abs)
25
+ entry = cov_data[file_abs]
26
+ lines_from_entry(entry)
27
+ end
28
+
29
+ private def find_stripped_match(file_abs)
30
+ return unless file_abs.start_with?(cwd_with_slash)
31
+
32
+ relative_path = file_abs[(cwd.length + 1)..]
33
+ entry = cov_data[relative_path]
34
+ lines_from_entry(entry)
35
+ end
36
+
37
+ private def cwd
38
+ @cwd ||= Dir.pwd
39
+ end
40
+
41
+ private def cwd_with_slash
42
+ @cwd_with_slash ||= "#{cwd}/"
43
+ end
44
+
45
+ private def raise_not_found_error(file_abs)
46
+ raise FileError, "No coverage entry found for #{file_abs}"
47
+ end
48
+
49
+ # Entry may store exact line coverage, branch-only coverage, or neither.
50
+ # Prefer the provided `lines` array but fall back to synthesizing one so
51
+ # callers always receive something enumerable.
52
+ #
53
+ # Returning nil tells callers to keep searching; the resolver will raise
54
+ # a FileError if no variant yields coverage data.
55
+ private def lines_from_entry(entry)
56
+ return unless entry.is_a?(Hash)
57
+
58
+ lines = entry['lines']
59
+ return lines if lines.is_a?(Array)
60
+
61
+ synthesize_lines_from_branches(entry['branches'])
62
+ end
63
+
64
+ # Some SimpleCov configurations track only branch coverage. When the
65
+ # resultset omits the legacy `lines` array we rebuild a minimal substitute
66
+ # so the rest of the pipeline (summaries, uncovered lines, staleness) can
67
+ # continue to operate.
68
+ #
69
+ # Branch data looks like:
70
+ # "[:if, 0, 12, 4, 20, 29]" => { "[:then, ...]" => hits, ... }
71
+ # We care about the third tuple element (line number). We sum branch-leg
72
+ # hits per line so the synthetic array still behaves like legacy line
73
+ # coverage (any positive value counts as executed).
74
+ private def synthesize_lines_from_branches(branch_data)
75
+ # Detailed shape and rationale documented in docs/BRANCH_ONLY_COVERAGE.md
76
+ return unless branch_data.is_a?(Hash) && branch_data.any?
77
+
78
+ line_hits = {}
79
+
80
+ branch_data
81
+ .values
82
+ .select { |targets| targets.is_a?(Hash) } # ignore malformed branch entries
83
+ .flat_map(&:to_a) # flatten each branch target into [meta, hits]
84
+ .filter_map do |meta, hits|
85
+ # Extract the covered line; filter_map discards nil results.
86
+ line_number = extract_line_number(meta)
87
+ line_number && [line_number, hits.to_i]
88
+ end
89
+ .each do |line_number, hits|
90
+ line_hits[line_number] = line_hits.fetch(line_number, 0) + hits
91
+ end
92
+
93
+ return if line_hits.empty?
94
+
95
+ max_line = line_hits.keys.max
96
+ # Build a dense array up to the highest line recorded so downstream
97
+ # consumers see the familiar SimpleCov shape (nil for untouched lines).
98
+ Array.new(max_line) { |idx| line_hits[idx + 1] }
99
+ end
100
+
101
+ # Branch metadata arrives as either the raw SimpleCov array
102
+ # (e.g. [:if, 0, 12, 4, 20, 29]) or the stringified JSON version
103
+ # ("[:if, 0, 12, 4, 20, 29]"). We normalize both forms and pull the line.
104
+ private def extract_line_number(meta)
105
+ if meta.is_a?(Array)
106
+ line_token = meta[2]
107
+ # Integer(..., exception: false) returns nil on failure, so malformed
108
+ # tuples quietly drop out of the synthesized array.
109
+ return Integer(line_token, exception: false)
110
+ end
111
+
112
+ tokens = meta.to_s.tr('[]', '').split(',').map(&:strip)
113
+ return if tokens.length < 3
114
+
115
+ Integer(tokens[2], exception: false)
116
+ # Any parsing errors result in nil; callers treat that as "no line".
117
+ rescue ArgumentError, TypeError
118
+ nil
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'resultset_path_resolver'
4
+ require_relative 'coverage_line_resolver'
5
+
6
+ module CovLoupe
7
+ module Resolvers
8
+ class ResolverFactory
9
+ def self.create_resultset_resolver(root: Dir.pwd, resultset: nil, candidates: nil)
10
+ candidates ?
11
+ ResultsetPathResolver.new(root: root, candidates: candidates) :
12
+ ResultsetPathResolver.new(root: root)
13
+ end
14
+
15
+ def self.create_coverage_resolver(cov_data)
16
+ CoverageLineResolver.new(cov_data)
17
+ end
18
+
19
+ def self.find_resultset(root, resultset: nil)
20
+ ResultsetPathResolver.new(root: root).find_resultset(resultset: resultset)
21
+ end
22
+
23
+ def self.lookup_lines(cov, file_abs)
24
+ CoverageLineResolver.new(cov).lookup_lines(file_abs)
25
+ end
26
+ end
27
+ end
28
+ end