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,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
|