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,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module CovLoupe
|
|
6
|
+
module Resolvers
|
|
7
|
+
class ResultsetPathResolver
|
|
8
|
+
DEFAULT_CANDIDATES = [
|
|
9
|
+
'.resultset.json',
|
|
10
|
+
'coverage/.resultset.json',
|
|
11
|
+
'tmp/.resultset.json'
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(root: Dir.pwd, candidates: DEFAULT_CANDIDATES)
|
|
15
|
+
@root = root
|
|
16
|
+
@candidates = candidates
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def find_resultset(resultset: nil)
|
|
20
|
+
if resultset && !resultset.empty?
|
|
21
|
+
path = normalize_resultset_path(resultset)
|
|
22
|
+
if (resolved = resolve_candidate(path, strict: true))
|
|
23
|
+
return resolved
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
resolve_fallback or raise_not_found_error
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private def resolve_candidate(path, strict:)
|
|
31
|
+
return path if File.file?(path)
|
|
32
|
+
return resolve_directory(path) if File.directory?(path)
|
|
33
|
+
|
|
34
|
+
raise_not_found_error_for_file(path) if strict
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private def resolve_directory(path)
|
|
39
|
+
candidate = File.join(path, '.resultset.json')
|
|
40
|
+
return candidate if File.file?(candidate)
|
|
41
|
+
|
|
42
|
+
raise "No .resultset.json found in directory: #{path}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private def raise_not_found_error_for_file(path)
|
|
46
|
+
raise "Specified resultset not found: #{path}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private def resolve_fallback
|
|
50
|
+
@candidates
|
|
51
|
+
.map { |p| File.absolute_path(p, @root) }
|
|
52
|
+
.find { |p| File.file?(p) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private def normalize_resultset_path(resultset)
|
|
56
|
+
candidate = Pathname.new(resultset)
|
|
57
|
+
return candidate.cleanpath.to_s if candidate.absolute?
|
|
58
|
+
|
|
59
|
+
expanded = File.expand_path(resultset, Dir.pwd)
|
|
60
|
+
return expanded if within_root?(expanded)
|
|
61
|
+
|
|
62
|
+
File.absolute_path(resultset, @root)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private def within_root?(path)
|
|
66
|
+
normalized_root = Pathname.new(@root).cleanpath.to_s
|
|
67
|
+
root_with_sep = normalized_root.end_with?(File::SEPARATOR) ? normalized_root : "#{normalized_root}#{File::SEPARATOR}"
|
|
68
|
+
path == normalized_root || path.start_with?(root_with_sep)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private def raise_not_found_error
|
|
72
|
+
raise "Could not find .resultset.json under #{@root.inspect}; run tests or set --resultset option"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
require_relative 'errors'
|
|
7
|
+
require_relative 'util'
|
|
8
|
+
|
|
9
|
+
module CovLoupe
|
|
10
|
+
class ResultsetLoader
|
|
11
|
+
Result = Struct.new(:coverage_map, :timestamp, :suite_names, keyword_init: true)
|
|
12
|
+
SuiteEntry = Struct.new(:name, :coverage, :timestamp, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def load(resultset_path:)
|
|
16
|
+
raw = JSON.load_file(resultset_path)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
suites = extract_suite_entries(raw, resultset_path)
|
|
20
|
+
raise CoverageDataError, "No test suite with coverage data found in resultset file: #{resultset_path}" if suites.empty?
|
|
21
|
+
|
|
22
|
+
coverage_map = build_coverage_map(suites, resultset_path)
|
|
23
|
+
Result.new(
|
|
24
|
+
coverage_map: coverage_map,
|
|
25
|
+
timestamp: compute_combined_timestamp(suites),
|
|
26
|
+
suite_names: suites.map(&:name)
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private def extract_suite_entries(raw, resultset_path)
|
|
31
|
+
raw
|
|
32
|
+
.select { |_, data| data.is_a?(Hash) && data.key?('coverage') && !data['coverage'].nil? }
|
|
33
|
+
.map do |name, data|
|
|
34
|
+
SuiteEntry.new(
|
|
35
|
+
name: name.to_s,
|
|
36
|
+
coverage: normalize_suite_coverage(data['coverage'], suite_name: name,
|
|
37
|
+
resultset_path: resultset_path),
|
|
38
|
+
timestamp: normalize_coverage_timestamp(data['timestamp'], data['created_at'])
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private def build_coverage_map(suites, resultset_path)
|
|
44
|
+
return suites.first&.coverage if suites.length == 1
|
|
45
|
+
|
|
46
|
+
merge_suite_coverages(suites, resultset_path)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private def normalize_suite_coverage(coverage, suite_name:, resultset_path:)
|
|
50
|
+
unless coverage.is_a?(Hash)
|
|
51
|
+
raise CoverageDataError, "Invalid coverage data structure for suite #{suite_name.inspect} in resultset file: #{resultset_path}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
needs_adaptation = coverage.values.any? { |value| value.is_a?(Array) }
|
|
55
|
+
return coverage unless needs_adaptation
|
|
56
|
+
|
|
57
|
+
coverage.transform_values do |value|
|
|
58
|
+
value.is_a?(Array) ? { 'lines' => value } : value
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private def merge_suite_coverages(suites, resultset_path)
|
|
63
|
+
require_simplecov_for_merge!(resultset_path)
|
|
64
|
+
log_duplicate_suite_names(suites)
|
|
65
|
+
|
|
66
|
+
suites.reduce(nil) do |memo, suite|
|
|
67
|
+
coverage = suite.coverage
|
|
68
|
+
memo ?
|
|
69
|
+
SimpleCov::Combine.combine(SimpleCov::Combine::ResultsCombiner, memo, coverage) :
|
|
70
|
+
coverage
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private def require_simplecov_for_merge!(resultset_path)
|
|
75
|
+
require 'simplecov'
|
|
76
|
+
rescue LoadError
|
|
77
|
+
raise CoverageDataError, "Multiple coverage suites detected in #{resultset_path}, but the simplecov gem could not be loaded. Install simplecov to enable suite merging."
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private def log_duplicate_suite_names(suites)
|
|
81
|
+
grouped = suites.group_by(&:name)
|
|
82
|
+
duplicates = grouped.select { |_, entries| entries.length > 1 }.keys
|
|
83
|
+
return if duplicates.empty?
|
|
84
|
+
|
|
85
|
+
message = "Merging duplicate coverage suites for #{duplicates.join(', ')}"
|
|
86
|
+
CovUtil.safe_log(message)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private def compute_combined_timestamp(suites)
|
|
90
|
+
suites.map(&:timestamp).compact.max.to_i
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private def normalize_coverage_timestamp(timestamp_value, created_at_value)
|
|
94
|
+
raw = timestamp_value.nil? ? created_at_value : timestamp_value
|
|
95
|
+
return 0 if raw.nil?
|
|
96
|
+
|
|
97
|
+
case raw
|
|
98
|
+
when Integer
|
|
99
|
+
raw
|
|
100
|
+
when Float, Time
|
|
101
|
+
raw.to_i
|
|
102
|
+
when String
|
|
103
|
+
normalize_string_timestamp(raw)
|
|
104
|
+
else
|
|
105
|
+
log_timestamp_warning(raw)
|
|
106
|
+
0
|
|
107
|
+
end
|
|
108
|
+
rescue => e
|
|
109
|
+
log_timestamp_warning(raw, e)
|
|
110
|
+
0
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private def normalize_string_timestamp(value)
|
|
114
|
+
str = value.strip
|
|
115
|
+
return 0 if str.empty?
|
|
116
|
+
|
|
117
|
+
if str.match?(/\A-?\d+(\.\d+)?\z/)
|
|
118
|
+
str.to_f.to_i
|
|
119
|
+
else
|
|
120
|
+
Time.parse(str).to_i
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private def log_timestamp_warning(raw_value, error = nil)
|
|
125
|
+
message = "Coverage resultset timestamp could not be parsed: #{raw_value.inspect}"
|
|
126
|
+
message = "#{message} (#{error.message})" if error
|
|
127
|
+
CovUtil.safe_log(message)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
require_relative 'util'
|
|
7
|
+
|
|
8
|
+
module CovLoupe
|
|
9
|
+
# Lightweight service object to check staleness of coverage vs. sources
|
|
10
|
+
class StalenessChecker
|
|
11
|
+
MODES = [:off, :error].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(root:, resultset:, mode: :off, tracked_globs: nil, timestamp: nil)
|
|
14
|
+
@root = File.absolute_path(root || '.')
|
|
15
|
+
@resultset = resultset
|
|
16
|
+
@mode = (mode || :off).to_sym
|
|
17
|
+
@tracked_globs = tracked_globs
|
|
18
|
+
@cov_timestamp = timestamp
|
|
19
|
+
@resultset_path = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def off?
|
|
23
|
+
@mode == :off
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Raise CoverageDataStaleError if stale (only in error mode)
|
|
27
|
+
def check_file!(file_abs, coverage_lines)
|
|
28
|
+
return if off?
|
|
29
|
+
|
|
30
|
+
d = compute_file_staleness_details(file_abs, coverage_lines)
|
|
31
|
+
# For single-file checks, missing files with recorded coverage count as stale
|
|
32
|
+
# via length mismatch; project-level checks also handle deleted files explicitly.
|
|
33
|
+
if d[:newer] || d[:len_mismatch]
|
|
34
|
+
raise CoverageDataStaleError.new(
|
|
35
|
+
nil,
|
|
36
|
+
nil,
|
|
37
|
+
file_path: rel(file_abs),
|
|
38
|
+
file_mtime: d[:file_mtime],
|
|
39
|
+
cov_timestamp: d[:coverage_timestamp],
|
|
40
|
+
src_len: d[:src_len],
|
|
41
|
+
cov_len: d[:cov_len],
|
|
42
|
+
resultset_path: resultset_path
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Compute whether a specific file appears stale relative to coverage.
|
|
48
|
+
# Ignores mode and never raises; returns true when:
|
|
49
|
+
# - the file is missing/deleted, or
|
|
50
|
+
# - the file mtime is newer than the coverage timestamp, or
|
|
51
|
+
# - the source line count differs from the coverage lines array length (when present).
|
|
52
|
+
def stale_for_file?(file_abs, coverage_lines)
|
|
53
|
+
d = compute_file_staleness_details(file_abs, coverage_lines)
|
|
54
|
+
return 'M' unless d[:exists]
|
|
55
|
+
return 'T' if d[:newer]
|
|
56
|
+
return 'L' if d[:len_mismatch]
|
|
57
|
+
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Raise CoverageDataProjectStaleError if any covered file is newer or if
|
|
62
|
+
# tracked files are missing from coverage, or coverage includes deleted files.
|
|
63
|
+
def check_project!(coverage_map)
|
|
64
|
+
return if off?
|
|
65
|
+
|
|
66
|
+
ts = coverage_timestamp
|
|
67
|
+
coverage_files = coverage_map.keys
|
|
68
|
+
|
|
69
|
+
newer, deleted = compute_newer_and_deleted_files(coverage_files, ts)
|
|
70
|
+
missing = compute_missing_files(coverage_files)
|
|
71
|
+
|
|
72
|
+
return if newer.empty? && missing.empty? && deleted.empty?
|
|
73
|
+
|
|
74
|
+
raise CoverageDataProjectStaleError.new(
|
|
75
|
+
nil,
|
|
76
|
+
nil,
|
|
77
|
+
cov_timestamp: ts,
|
|
78
|
+
newer_files: newer,
|
|
79
|
+
missing_files: missing,
|
|
80
|
+
deleted_files: deleted,
|
|
81
|
+
resultset_path: resultset_path
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private def compute_newer_and_deleted_files(coverage_files, timestamp)
|
|
86
|
+
existing, deleted_abs = coverage_files.partition { |abs| File.file?(abs) }
|
|
87
|
+
|
|
88
|
+
newer = existing
|
|
89
|
+
.select { |abs| File.mtime(abs).to_i > timestamp.to_i }
|
|
90
|
+
.map { |abs| rel(abs) }
|
|
91
|
+
deleted = deleted_abs.map { |abs| rel(abs) }
|
|
92
|
+
|
|
93
|
+
[newer, deleted]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Identifies tracked files that are missing from coverage.
|
|
97
|
+
# Returns array of relative paths for files matched by tracked_globs but not in coverage.
|
|
98
|
+
private def compute_missing_files(coverage_files)
|
|
99
|
+
return [] unless @tracked_globs && Array(@tracked_globs).any?
|
|
100
|
+
|
|
101
|
+
patterns = Array(@tracked_globs).map { |g| File.absolute_path(g, @root) }
|
|
102
|
+
tracked = patterns
|
|
103
|
+
.flat_map { |p| Dir.glob(p, File::FNM_EXTGLOB | File::FNM_PATHNAME) }
|
|
104
|
+
.select { |p| File.file?(p) }
|
|
105
|
+
|
|
106
|
+
covered_set = coverage_files.to_set
|
|
107
|
+
tracked.reject { |abs| covered_set.include?(abs) }.map { |abs| rel(abs) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private def coverage_timestamp
|
|
111
|
+
@cov_timestamp || 0
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private def resultset_path
|
|
115
|
+
@resultset_path ||= CovUtil.find_resultset(@root, resultset: @resultset)
|
|
116
|
+
rescue
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private def safe_count_lines(path)
|
|
121
|
+
return 0 unless File.file?(path)
|
|
122
|
+
|
|
123
|
+
File.foreach(path).count
|
|
124
|
+
rescue
|
|
125
|
+
0
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private def missing_trailing_newline?(path)
|
|
129
|
+
return false unless File.file?(path)
|
|
130
|
+
|
|
131
|
+
File.open(path, 'rb') do |f|
|
|
132
|
+
size = f.size
|
|
133
|
+
return false if size.zero?
|
|
134
|
+
|
|
135
|
+
f.seek(-1, IO::SEEK_END)
|
|
136
|
+
f.getbyte != 0x0A
|
|
137
|
+
end
|
|
138
|
+
rescue
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private def rel(path)
|
|
143
|
+
# Handle relative vs absolute path mismatches that cause ArgumentError
|
|
144
|
+
Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
|
|
145
|
+
rescue ArgumentError
|
|
146
|
+
# Path is outside the project root or has a different prefix type, fall back to absolute path
|
|
147
|
+
path.to_s
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Centralized computation of staleness-related details for a single file.
|
|
151
|
+
# Returns a Hash with keys:
|
|
152
|
+
# :exists, :file_mtime, :coverage_timestamp, :cov_len, :src_len, :newer, :len_mismatch
|
|
153
|
+
private def compute_file_staleness_details(file_abs, coverage_lines)
|
|
154
|
+
coverage_ts = coverage_timestamp
|
|
155
|
+
|
|
156
|
+
exists = File.file?(file_abs)
|
|
157
|
+
file_mtime = exists ? File.mtime(file_abs) : nil
|
|
158
|
+
|
|
159
|
+
cov_len = coverage_lines.respond_to?(:length) ? coverage_lines.length : 0
|
|
160
|
+
src_len = exists ? safe_count_lines(file_abs) : 0
|
|
161
|
+
|
|
162
|
+
# Adjust source line count to handle edge cases with missing trailing newlines
|
|
163
|
+
adjusted_src_len = adjust_line_count_for_missing_newline(
|
|
164
|
+
file_abs: file_abs,
|
|
165
|
+
exists: exists,
|
|
166
|
+
cov_len: cov_len,
|
|
167
|
+
src_len: src_len
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Check if the source file has been modified since coverage was generated
|
|
171
|
+
len_mismatch = length_mismatch?(cov_len, adjusted_src_len)
|
|
172
|
+
newer = check_file_newer_than_coverage(file_mtime, coverage_ts, len_mismatch)
|
|
173
|
+
|
|
174
|
+
{
|
|
175
|
+
exists: exists,
|
|
176
|
+
file_mtime: file_mtime,
|
|
177
|
+
coverage_timestamp: coverage_ts,
|
|
178
|
+
cov_len: cov_len,
|
|
179
|
+
src_len: src_len,
|
|
180
|
+
newer: newer,
|
|
181
|
+
len_mismatch: len_mismatch
|
|
182
|
+
}
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Adjusts the source line count to account for files missing trailing newlines.
|
|
186
|
+
#
|
|
187
|
+
# Why this edge case exists:
|
|
188
|
+
# - File.foreach counts lines by separator (typically \n)
|
|
189
|
+
# - For a file with no trailing newline, File.foreach still counts all lines correctly
|
|
190
|
+
# - However, some editors or file operations may report one extra line when checking
|
|
191
|
+
# if the file doesn't end with a newline
|
|
192
|
+
# - SimpleCov's coverage array length matches the logical line count (excluding trailing newline)
|
|
193
|
+
# - If src_len is exactly one more than cov_len AND the file is missing a trailing newline,
|
|
194
|
+
# we adjust src_len down by 1 to match SimpleCov's convention
|
|
195
|
+
#
|
|
196
|
+
# Example: A file with "line1\nline2\nline3" (no final \n)
|
|
197
|
+
# - File.foreach counts: 3 lines
|
|
198
|
+
# - SimpleCov coverage array length: 3
|
|
199
|
+
# - No adjustment needed
|
|
200
|
+
#
|
|
201
|
+
# However, in certain edge cases where the file system or parsing reports an extra line:
|
|
202
|
+
# - Reported line count: 4
|
|
203
|
+
# - SimpleCov coverage array length: 3
|
|
204
|
+
# - Missing trailing newline: true
|
|
205
|
+
# - Adjustment: 4 - 1 = 3 (now matches)
|
|
206
|
+
private def adjust_line_count_for_missing_newline(file_abs:, exists:, cov_len:, src_len:)
|
|
207
|
+
# Only adjust if:
|
|
208
|
+
# 1. File exists (can't check newlines for missing files)
|
|
209
|
+
# 2. Coverage data is present (cov_len > 0)
|
|
210
|
+
# 3. Source has exactly one more line than coverage
|
|
211
|
+
# 4. File is missing a trailing newline
|
|
212
|
+
needs_adjusting =
|
|
213
|
+
exists && cov_len.positive? && src_len == cov_len + 1 && missing_trailing_newline?(file_abs)
|
|
214
|
+
needs_adjusting ? src_len - 1 : src_len
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Checks if the source line count differs from the coverage line count.
|
|
218
|
+
#
|
|
219
|
+
# Why this check exists:
|
|
220
|
+
# - When a file is modified after coverage is generated, the line count often changes
|
|
221
|
+
# - A mismatch indicates the coverage data is stale and no longer represents the current file
|
|
222
|
+
# - We only flag as mismatch when coverage data exists (cov_len > 0)
|
|
223
|
+
#
|
|
224
|
+
# Note: Empty coverage (cov_len == 0) is not considered a mismatch, as it may represent
|
|
225
|
+
# files that were never executed or files that are legitimately empty.
|
|
226
|
+
private def length_mismatch?(cov_len, adjusted_src_len)
|
|
227
|
+
cov_len.positive? && adjusted_src_len != cov_len
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Determines if a file has been modified more recently than the coverage timestamp.
|
|
231
|
+
#
|
|
232
|
+
# Why this check exists:
|
|
233
|
+
# - Files modified after coverage generation may have behavioral changes not captured
|
|
234
|
+
# - However, if there's already a length mismatch, we prioritize that as the staleness indicator
|
|
235
|
+
# - This prevents double-flagging: if lines changed, the file is already stale (length mismatch)
|
|
236
|
+
#
|
|
237
|
+
# The logic: newer &&= !len_mismatch means:
|
|
238
|
+
# - If len_mismatch is true, set newer to false (length mismatch takes precedence)
|
|
239
|
+
# - This way, staleness is categorized as either 'T' (time-based) OR 'L' (length-based), not both
|
|
240
|
+
private def check_file_newer_than_coverage(file_mtime, coverage_ts, len_mismatch)
|
|
241
|
+
newer = !!(file_mtime && file_mtime.to_i > coverage_ts.to_i)
|
|
242
|
+
# If there's a length mismatch, don't also flag as "newer" - the mismatch is more specific
|
|
243
|
+
newer &&= !len_mismatch
|
|
244
|
+
newer
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CovLoupe
|
|
4
|
+
# General-purpose table formatter with box-drawing characters
|
|
5
|
+
# Used by commands to create consistent formatted output
|
|
6
|
+
class TableFormatter
|
|
7
|
+
# Format data as a table with box-drawing characters
|
|
8
|
+
# @param headers [Array<String>] Column headers
|
|
9
|
+
# @param rows [Array<Array>] Data rows (each row is an array of cell values)
|
|
10
|
+
# @param alignments [Array<Symbol>] Column alignments (:left, :right, :center)
|
|
11
|
+
# @return [String] Formatted table
|
|
12
|
+
def self.format(headers:, rows:, alignments: nil)
|
|
13
|
+
return 'No data to display' if rows.empty?
|
|
14
|
+
|
|
15
|
+
alignments ||= [:left] * headers.size
|
|
16
|
+
all_rows = [headers] + rows.map { |row| row.map(&:to_s) }
|
|
17
|
+
|
|
18
|
+
# Calculate column widths
|
|
19
|
+
widths = headers.size.times.map do |col|
|
|
20
|
+
all_rows.map { |row| row[col].to_s.length }.max
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
lines = []
|
|
24
|
+
lines << border_line(widths, '┌', '┬', '┐')
|
|
25
|
+
lines << data_row(headers, widths, alignments)
|
|
26
|
+
lines << border_line(widths, '├', '┼', '┤')
|
|
27
|
+
rows.each { |row| lines << data_row(row, widths, alignments) }
|
|
28
|
+
lines << border_line(widths, '└', '┴', '┘')
|
|
29
|
+
|
|
30
|
+
lines.join("\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Format a single key-value table (vertical layout)
|
|
34
|
+
# @param data [Hash] Key-value pairs
|
|
35
|
+
# @return [String] Formatted table
|
|
36
|
+
def self.format_vertical(data)
|
|
37
|
+
rows = data.map { |k, v| [k.to_s, v.to_s] }
|
|
38
|
+
format(headers: ['Key', 'Value'], rows: rows, alignments: [:left, :left])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private_class_method def self.border_line(widths, left, mid, right)
|
|
42
|
+
segments = widths.map { |w| '─' * (w + 2) }
|
|
43
|
+
left + segments.join(mid) + right
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private_class_method def self.data_row(cells, widths, alignments)
|
|
47
|
+
formatted = cells.each_with_index.map do |cell, i|
|
|
48
|
+
align_cell(cell.to_s, widths[i], alignments[i])
|
|
49
|
+
end
|
|
50
|
+
"│ #{formatted.join(' │ ')} │"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private_class_method def self.align_cell(content, width, alignment)
|
|
54
|
+
case alignment
|
|
55
|
+
when :right
|
|
56
|
+
content.rjust(width)
|
|
57
|
+
when :center
|
|
58
|
+
content.center(width)
|
|
59
|
+
else # :left
|
|
60
|
+
content.ljust(width)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../model'
|
|
4
|
+
require_relative '../base_tool'
|
|
5
|
+
require_relative '../presenters/project_coverage_presenter'
|
|
6
|
+
|
|
7
|
+
module CovLoupe
|
|
8
|
+
module Tools
|
|
9
|
+
class AllFilesCoverageTool < BaseTool
|
|
10
|
+
description <<~DESC
|
|
11
|
+
Use this when the user wants coverage percentages for every tracked file in the project.
|
|
12
|
+
Do not use this for single-file stats; prefer coverage.summary or coverage.uncovered_lines for that.
|
|
13
|
+
Inputs: optional project root, alternate .resultset path, sort order, staleness mode, and tracked_globs to alert on new files.
|
|
14
|
+
Output: JSON {"files": [{"file","covered","total","percentage","stale"}, ...], "counts": {"total", "ok", "stale"}} sorted as requested. "stale" is a string ('M', 'T', 'L') or false.
|
|
15
|
+
Examples: "List files with the lowest coverage"; "Show repo coverage sorted descending".
|
|
16
|
+
DESC
|
|
17
|
+
input_schema(**coverage_schema(
|
|
18
|
+
additional_properties: {
|
|
19
|
+
sort_order: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description: 'Sort order for coverage percentages.' \
|
|
22
|
+
"'ascending' highlights the riskiest files first.",
|
|
23
|
+
default: 'ascending',
|
|
24
|
+
enum: ['ascending', 'descending']
|
|
25
|
+
},
|
|
26
|
+
tracked_globs: TRACKED_GLOBS_PROPERTY
|
|
27
|
+
}
|
|
28
|
+
))
|
|
29
|
+
class << self
|
|
30
|
+
def call(root: '.', resultset: nil, sort_order: 'ascending', staleness: :off,
|
|
31
|
+
tracked_globs: nil, error_mode: 'log', server_context:)
|
|
32
|
+
with_error_handling('AllFilesCoverageTool', error_mode: error_mode) do
|
|
33
|
+
# Convert string inputs from MCP to symbols for internal use
|
|
34
|
+
sort_order_sym = sort_order.to_sym
|
|
35
|
+
staleness_sym = staleness.to_sym
|
|
36
|
+
|
|
37
|
+
model = CoverageModel.new(root: root, resultset: resultset, staleness: staleness_sym,
|
|
38
|
+
tracked_globs: tracked_globs)
|
|
39
|
+
presenter = Presenters::ProjectCoveragePresenter.new(
|
|
40
|
+
model: model,
|
|
41
|
+
sort_order: sort_order_sym,
|
|
42
|
+
check_stale: (staleness_sym == :error),
|
|
43
|
+
tracked_globs: tracked_globs
|
|
44
|
+
)
|
|
45
|
+
respond_json(presenter.relativized_payload, name: 'all_files_coverage.json')
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../base_tool'
|
|
4
|
+
require_relative '../model'
|
|
5
|
+
require_relative '../presenters/coverage_detailed_presenter'
|
|
6
|
+
|
|
7
|
+
module CovLoupe
|
|
8
|
+
module Tools
|
|
9
|
+
class CoverageDetailedTool < BaseTool
|
|
10
|
+
description <<~DESC
|
|
11
|
+
Use this when the user needs per-line coverage data for a single file.
|
|
12
|
+
Do not use this for high-level counts; coverage.summary is cheaper for aggregate numbers.
|
|
13
|
+
Inputs: file path (required) plus optional root/resultset/staleness mode inherited from BaseTool.
|
|
14
|
+
Output: JSON object with "file", "lines" => [{"line": 12, "hits": 0, "covered": false}], plus "summary" with totals and "stale" status.
|
|
15
|
+
Example: "Show detailed coverage for lib/cov_loupe/model.rb".
|
|
16
|
+
DESC
|
|
17
|
+
input_schema(**input_schema_def)
|
|
18
|
+
class << self
|
|
19
|
+
def call(path:, root: '.', resultset: nil, staleness: :off, error_mode: 'log',
|
|
20
|
+
server_context:)
|
|
21
|
+
with_error_handling('CoverageDetailedTool', error_mode: error_mode) do
|
|
22
|
+
model = CoverageModel.new(
|
|
23
|
+
root: root,
|
|
24
|
+
resultset: resultset,
|
|
25
|
+
staleness: staleness.to_sym
|
|
26
|
+
)
|
|
27
|
+
presenter = Presenters::CoverageDetailedPresenter.new(model: model, path: path)
|
|
28
|
+
respond_json(presenter.relativized_payload, name: 'coverage_detailed.json',
|
|
29
|
+
pretty: true)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../base_tool'
|
|
4
|
+
require_relative '../model'
|
|
5
|
+
require_relative '../presenters/coverage_raw_presenter'
|
|
6
|
+
|
|
7
|
+
module CovLoupe
|
|
8
|
+
module Tools
|
|
9
|
+
class CoverageRawTool < BaseTool
|
|
10
|
+
description <<~DESC
|
|
11
|
+
Use this when you need the raw SimpleCov `lines` array for a file exactly as stored on disk.
|
|
12
|
+
Do not use this for human-friendly explanations; choose coverage.detailed or coverage.summary instead.
|
|
13
|
+
Inputs: file path (required) plus optional root/resultset/staleness mode inherited from BaseTool.
|
|
14
|
+
Output: JSON object with "file" and "lines" (array of integers/nulls) mirroring SimpleCov's native structure, plus "stale" status.
|
|
15
|
+
Example: "Fetch the raw coverage array for spec/support/foo_helper.rb".
|
|
16
|
+
DESC
|
|
17
|
+
input_schema(**input_schema_def)
|
|
18
|
+
class << self
|
|
19
|
+
def call(path:, root: '.', resultset: nil, staleness: :off, error_mode: 'log',
|
|
20
|
+
server_context:)
|
|
21
|
+
with_error_handling('CoverageRawTool', error_mode: error_mode) do
|
|
22
|
+
model = CoverageModel.new(
|
|
23
|
+
root: root,
|
|
24
|
+
resultset: resultset,
|
|
25
|
+
staleness: staleness.to_sym
|
|
26
|
+
)
|
|
27
|
+
presenter = Presenters::CoverageRawPresenter.new(model: model, path: path)
|
|
28
|
+
respond_json(presenter.relativized_payload, name: 'coverage_raw.json', pretty: true)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../base_tool'
|
|
4
|
+
require_relative '../model'
|
|
5
|
+
require_relative '../presenters/coverage_summary_presenter'
|
|
6
|
+
|
|
7
|
+
module CovLoupe
|
|
8
|
+
module Tools
|
|
9
|
+
class CoverageSummaryTool < BaseTool
|
|
10
|
+
description <<~DESC
|
|
11
|
+
Use this when the user asks for the covered/total line counts and percentage for a specific file.
|
|
12
|
+
Do not use this for multi-file reports; coverage.all_files or coverage.table handle those.
|
|
13
|
+
Inputs: file path (required) plus optional root/resultset/staleness mode inherited from BaseTool.
|
|
14
|
+
Output: JSON object {"file": String, "summary": {"covered": Integer, "total": Integer, "percentage": Float}, "stale": String|False}.
|
|
15
|
+
Examples: "What is the coverage for lib/cov_loupe/tools/all_files_coverage_tool.rb?".
|
|
16
|
+
DESC
|
|
17
|
+
input_schema(**input_schema_def)
|
|
18
|
+
class << self
|
|
19
|
+
def call(path:, root: '.', resultset: nil, staleness: :off, error_mode: 'log',
|
|
20
|
+
server_context:)
|
|
21
|
+
with_error_handling('CoverageSummaryTool', error_mode: error_mode) do
|
|
22
|
+
model = CoverageModel.new(
|
|
23
|
+
root: root,
|
|
24
|
+
resultset: resultset,
|
|
25
|
+
staleness: staleness.to_sym
|
|
26
|
+
)
|
|
27
|
+
presenter = Presenters::CoverageSummaryPresenter.new(model: model, path: path)
|
|
28
|
+
respond_json(presenter.relativized_payload, name: 'coverage_summary.json', pretty: true)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|