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,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'constants'
|
|
4
|
+
|
|
5
|
+
module CovLoupe
|
|
6
|
+
# Centralizes the logic for detecting whether to run in CLI or MCP server mode.
|
|
7
|
+
# This makes the mode detection strategy explicit and testable.
|
|
8
|
+
class ModeDetector
|
|
9
|
+
SUBCOMMANDS = %w[list summary raw uncovered detailed totals validate version].freeze
|
|
10
|
+
|
|
11
|
+
# Reference shared constant to avoid duplication with CoverageCLI
|
|
12
|
+
OPTIONS_EXPECTING_ARGUMENT = Constants::OPTIONS_EXPECTING_ARGUMENT
|
|
13
|
+
|
|
14
|
+
def self.cli_mode?(argv, stdin: $stdin)
|
|
15
|
+
# 1. Explicit flags that force CLI mode always win
|
|
16
|
+
cli_options = %w[--force-cli -h --help --version -v]
|
|
17
|
+
return true if argv.intersect?(cli_options)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# 2. Find the first non-option argument
|
|
21
|
+
first_non_option = find_first_non_option(argv)
|
|
22
|
+
|
|
23
|
+
# 3. If a non-option argument exists, it must be a CLI command (or an error)
|
|
24
|
+
return true if first_non_option
|
|
25
|
+
|
|
26
|
+
# 4. Fallback: If no non-option args, use TTY status to decide
|
|
27
|
+
stdin.tty?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.mcp_server_mode?(argv, stdin: $stdin)
|
|
31
|
+
!cli_mode?(argv, stdin: stdin)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Scans argv and returns the first token that is not an option or a value for an option.
|
|
35
|
+
def self.find_first_non_option(argv)
|
|
36
|
+
pending_option = false
|
|
37
|
+
argv.each do |token|
|
|
38
|
+
if pending_option
|
|
39
|
+
pending_option = false
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if token.start_with?('-')
|
|
44
|
+
# Check if the option is one that takes a value and isn't using '=' syntax.
|
|
45
|
+
pending_option = OPTIONS_EXPECTING_ARGUMENT.include?(token) && !token.include?('=')
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Found the first token that is not an option
|
|
50
|
+
return token
|
|
51
|
+
end
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
private_class_method :find_first_non_option
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
require_relative 'util'
|
|
7
|
+
require_relative 'errors'
|
|
8
|
+
require_relative 'error_handler'
|
|
9
|
+
require_relative 'staleness_checker'
|
|
10
|
+
require_relative 'path_relativizer'
|
|
11
|
+
require_relative 'resultset_loader'
|
|
12
|
+
|
|
13
|
+
module CovLoupe
|
|
14
|
+
class CoverageModel
|
|
15
|
+
RELATIVIZER_SCALAR_KEYS = %w[file file_path].freeze
|
|
16
|
+
RELATIVIZER_ARRAY_KEYS = %w[newer_files missing_files deleted_files].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :relativizer
|
|
19
|
+
|
|
20
|
+
# Create a CoverageModel
|
|
21
|
+
#
|
|
22
|
+
# Params:
|
|
23
|
+
# - root: project root directory (default '.')
|
|
24
|
+
# - resultset: path or directory to .resultset.json
|
|
25
|
+
# - staleness: :off or :error (default :off). When :error, raises
|
|
26
|
+
# stale errors if sources are newer than coverage or line counts mismatch.
|
|
27
|
+
# - tracked_globs: only used for all_files project-level staleness.
|
|
28
|
+
def initialize(root: '.', resultset: nil, staleness: :off, tracked_globs: nil)
|
|
29
|
+
@root = File.absolute_path(root || '.')
|
|
30
|
+
@resultset = resultset
|
|
31
|
+
@relativizer = PathRelativizer.new(
|
|
32
|
+
root: @root,
|
|
33
|
+
scalar_keys: RELATIVIZER_SCALAR_KEYS,
|
|
34
|
+
array_keys: RELATIVIZER_ARRAY_KEYS
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
load_coverage_data(resultset, staleness, tracked_globs)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns { 'file' => <absolute_path>, 'lines' => [hits|nil,...] }
|
|
41
|
+
def raw_for(path)
|
|
42
|
+
file_abs, coverage_lines = coverage_data_for(path)
|
|
43
|
+
{ 'file' => file_abs, 'lines' => coverage_lines }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def relativize(payload)
|
|
47
|
+
relativizer.relativize(payload)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, 'percentage'=>} }
|
|
51
|
+
def summary_for(path)
|
|
52
|
+
file_abs, coverage_lines = coverage_data_for(path)
|
|
53
|
+
{ 'file' => file_abs, 'summary' => CovUtil.summary(coverage_lines) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns { 'file' => <absolute_path>, 'uncovered' => [line,...], 'summary' => {...} }
|
|
57
|
+
def uncovered_for(path)
|
|
58
|
+
file_abs, coverage_lines = coverage_data_for(path)
|
|
59
|
+
{
|
|
60
|
+
'file' => file_abs,
|
|
61
|
+
'uncovered' => CovUtil.uncovered(coverage_lines),
|
|
62
|
+
'summary' => CovUtil.summary(coverage_lines)
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns { 'file' => <absolute_path>, 'lines' => [{'line'=>,'hits'=>,'covered'=>},...], 'summary' => {...} }
|
|
67
|
+
def detailed_for(path)
|
|
68
|
+
file_abs, coverage_lines = coverage_data_for(path)
|
|
69
|
+
{
|
|
70
|
+
'file' => file_abs,
|
|
71
|
+
'lines' => CovUtil.detailed(coverage_lines),
|
|
72
|
+
'summary' => CovUtil.summary(coverage_lines)
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns [ { 'file' =>, 'covered' =>, 'total' =>, 'percentage' =>, 'stale' => }, ... ]
|
|
77
|
+
def all_files(sort_order: :descending, check_stale: !@checker.off?, tracked_globs: nil)
|
|
78
|
+
stale_checker = build_staleness_checker(mode: :off, tracked_globs: tracked_globs)
|
|
79
|
+
|
|
80
|
+
rows = @cov.map do |abs_path, _data|
|
|
81
|
+
begin
|
|
82
|
+
coverage_lines = CovUtil.lookup_lines(@cov, abs_path)
|
|
83
|
+
rescue FileError
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
s = CovUtil.summary(coverage_lines)
|
|
88
|
+
stale = stale_checker.stale_for_file?(abs_path, coverage_lines)
|
|
89
|
+
{
|
|
90
|
+
'file' => abs_path,
|
|
91
|
+
'covered' => s['covered'],
|
|
92
|
+
'total' => s['total'],
|
|
93
|
+
'percentage' => s['percentage'],
|
|
94
|
+
'stale' => stale
|
|
95
|
+
}
|
|
96
|
+
end.compact
|
|
97
|
+
|
|
98
|
+
rows = filter_rows_by_globs(rows, tracked_globs)
|
|
99
|
+
|
|
100
|
+
if check_stale
|
|
101
|
+
build_staleness_checker(mode: :error, tracked_globs: tracked_globs).check_project!(@cov)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
sort_rows(rows, sort_order: sort_order)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def project_totals(tracked_globs: nil, check_stale: !@checker.off?)
|
|
108
|
+
rows = all_files(sort_order: :ascending, check_stale: check_stale,
|
|
109
|
+
tracked_globs: tracked_globs)
|
|
110
|
+
totals_from_rows(rows)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def staleness_for(path)
|
|
114
|
+
file_abs = File.absolute_path(path, @root)
|
|
115
|
+
coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
|
|
116
|
+
@checker.stale_for_file?(file_abs, coverage_lines)
|
|
117
|
+
rescue => e
|
|
118
|
+
CovUtil.safe_log("Failed to check staleness for #{path}: #{e.message}")
|
|
119
|
+
false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Returns formatted table string for all files coverage data
|
|
123
|
+
def format_table(rows = nil, sort_order: :descending, check_stale: !@checker.off?,
|
|
124
|
+
tracked_globs: nil)
|
|
125
|
+
rows = prepare_rows(rows, sort_order: sort_order, check_stale: check_stale,
|
|
126
|
+
tracked_globs: tracked_globs)
|
|
127
|
+
return 'No coverage data found' if rows.empty?
|
|
128
|
+
|
|
129
|
+
widths = compute_table_widths(rows)
|
|
130
|
+
lines = []
|
|
131
|
+
lines << border_line(widths, '┌', '┬', '┐')
|
|
132
|
+
lines << header_row(widths)
|
|
133
|
+
lines << border_line(widths, '├', '┼', '┤')
|
|
134
|
+
rows.each { |file_data| lines << data_row(file_data, widths) }
|
|
135
|
+
lines << border_line(widths, '└', '┴', '┘')
|
|
136
|
+
lines << summary_counts(rows)
|
|
137
|
+
if rows.any? { |f| f['stale'] }
|
|
138
|
+
lines <<
|
|
139
|
+
'Staleness: M = Missing file, T = Timestamp (source newer), L = Line count mismatch'
|
|
140
|
+
end
|
|
141
|
+
lines.join("\n")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private def load_coverage_data(resultset, staleness, tracked_globs)
|
|
145
|
+
rs = CovUtil.find_resultset(@root, resultset: resultset)
|
|
146
|
+
loaded = ResultsetLoader.load(resultset_path: rs)
|
|
147
|
+
coverage_map = loaded.coverage_map or raise(CoverageDataError, "No 'coverage' key found in resultset file: #{rs}")
|
|
148
|
+
|
|
149
|
+
@cov = coverage_map.transform_keys { |k| File.absolute_path(k, @root) }
|
|
150
|
+
@cov_timestamp = loaded.timestamp
|
|
151
|
+
|
|
152
|
+
@checker = StalenessChecker.new(
|
|
153
|
+
root: @root,
|
|
154
|
+
resultset: @resultset,
|
|
155
|
+
mode: staleness,
|
|
156
|
+
tracked_globs: tracked_globs,
|
|
157
|
+
timestamp: @cov_timestamp
|
|
158
|
+
)
|
|
159
|
+
rescue CovLoupe::Error
|
|
160
|
+
raise # Re-raise our own errors as-is
|
|
161
|
+
rescue => e
|
|
162
|
+
raise ErrorHandler.new.convert_standard_error(e, context: :coverage_loading)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private def build_staleness_checker(mode:, tracked_globs:)
|
|
166
|
+
StalenessChecker.new(
|
|
167
|
+
root: @root,
|
|
168
|
+
resultset: @resultset,
|
|
169
|
+
mode: mode,
|
|
170
|
+
tracked_globs: tracked_globs,
|
|
171
|
+
timestamp: @cov_timestamp
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private def prepare_rows(rows, sort_order:, check_stale:, tracked_globs:)
|
|
176
|
+
if rows.nil?
|
|
177
|
+
all_files(sort_order: sort_order, check_stale: check_stale, tracked_globs: tracked_globs)
|
|
178
|
+
else
|
|
179
|
+
rows = sort_rows(rows.dup, sort_order: sort_order)
|
|
180
|
+
filter_rows_by_globs(rows, tracked_globs)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private def sort_rows(rows, sort_order: :descending)
|
|
185
|
+
rows.sort do |a, b|
|
|
186
|
+
pct_cmp = (sort_order == :descending) \
|
|
187
|
+
? (b['percentage'] <=> a['percentage'])
|
|
188
|
+
: (a['percentage'] <=> b['percentage'])
|
|
189
|
+
pct_cmp == 0 ? (a['file'] <=> b['file']) : pct_cmp
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private def compute_table_widths(rows)
|
|
194
|
+
max_file_length = rows.map { |f| f['file'].length }.max.to_i
|
|
195
|
+
file_width = [max_file_length, 'File'.length].max + 2
|
|
196
|
+
pct_width = 8
|
|
197
|
+
max_covered = rows.map { |f| f['covered'].to_s.length }.max
|
|
198
|
+
max_total = rows.map { |f| f['total'].to_s.length }.max
|
|
199
|
+
covered_width = [max_covered, 'Covered'.length].max + 2
|
|
200
|
+
total_width = [max_total, 'Total'.length].max + 2
|
|
201
|
+
stale_width = 'Stale'.length
|
|
202
|
+
{
|
|
203
|
+
file: file_width,
|
|
204
|
+
pct: pct_width,
|
|
205
|
+
covered: covered_width,
|
|
206
|
+
total: total_width,
|
|
207
|
+
stale: stale_width
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private def border_line(widths, left, middle, right)
|
|
212
|
+
h_line = ->(col_width) { '─' * (col_width + 2) }
|
|
213
|
+
left +
|
|
214
|
+
h_line.call(widths[:file]) +
|
|
215
|
+
middle + h_line.call(widths[:pct]) +
|
|
216
|
+
middle + h_line.call(widths[:covered]) +
|
|
217
|
+
middle + h_line.call(widths[:total]) +
|
|
218
|
+
middle + h_line.call(widths[:stale]) +
|
|
219
|
+
right
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private def header_row(widths)
|
|
223
|
+
format(
|
|
224
|
+
"│ %-#{widths[:file]}s │ %#{widths[:pct]}s │ %#{widths[:covered]}s │ %#{widths[:total]}s │ %#{widths[:stale]}s │",
|
|
225
|
+
'File', ' %', 'Covered', 'Total', 'Stale'.center(widths[:stale])
|
|
226
|
+
)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
private def data_row(file_data, widths)
|
|
230
|
+
stale_text_str = file_data['stale'] ? file_data['stale'].to_s : ''
|
|
231
|
+
format(
|
|
232
|
+
"│ %-#{widths[:file]}s │ %#{widths[:pct] - 1}.2f%% │ %#{widths[:covered]}d │ %#{widths[:total]}d │ %#{widths[:stale]}s │",
|
|
233
|
+
file_data['file'],
|
|
234
|
+
file_data['percentage'],
|
|
235
|
+
file_data['covered'],
|
|
236
|
+
file_data['total'],
|
|
237
|
+
stale_text_str.center(widths[:stale])
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
private def summary_counts(rows)
|
|
242
|
+
total = rows.length
|
|
243
|
+
stale_count = rows.count { |f| f['stale'] }
|
|
244
|
+
ok_count = total - stale_count
|
|
245
|
+
"Files: total #{total}, ok #{ok_count}, stale #{stale_count}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Filters coverage rows to only include files matching the given glob patterns.
|
|
249
|
+
#
|
|
250
|
+
# @param rows [Array<Hash>] coverage rows with 'file' keys containing absolute paths
|
|
251
|
+
# @param tracked_globs [Array<String>, String, nil] glob patterns to match against
|
|
252
|
+
# @return [Array<Hash>] rows whose files match at least one pattern (or all rows if no patterns)
|
|
253
|
+
private def filter_rows_by_globs(rows, tracked_globs)
|
|
254
|
+
patterns = Array(tracked_globs).compact.map(&:to_s).reject(&:empty?)
|
|
255
|
+
return rows if patterns.empty?
|
|
256
|
+
|
|
257
|
+
absolute_patterns = patterns.map { |p| absolutize_pattern(p) }
|
|
258
|
+
rows.select { |row| matches_any_pattern?(row['file'], absolute_patterns) }
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Converts a relative pattern to absolute by joining with root.
|
|
262
|
+
# Absolute patterns are returned unchanged.
|
|
263
|
+
#
|
|
264
|
+
# @param pattern [String] glob pattern (e.g., "lib/**/*.rb")
|
|
265
|
+
# @return [String] absolute pattern
|
|
266
|
+
private def absolutize_pattern(pattern)
|
|
267
|
+
absolute_pattern?(pattern) ? pattern : File.join(@root, pattern)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Checks if a pattern is absolute, handling both Unix and Windows-style paths.
|
|
271
|
+
# On Unix, Pathname won't recognize "C:/" as absolute, so we check explicitly.
|
|
272
|
+
#
|
|
273
|
+
# @param pattern [String] glob pattern
|
|
274
|
+
# @return [Boolean] true if pattern is absolute
|
|
275
|
+
private def absolute_pattern?(pattern)
|
|
276
|
+
Pathname.new(pattern).absolute? || pattern.match?(/\A[A-Za-z]:/)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Tests if a file path matches any of the given absolute glob patterns.
|
|
280
|
+
# Uses File.fnmatch? for pure string matching without filesystem access,
|
|
281
|
+
# which is faster and works for paths that may no longer exist on disk.
|
|
282
|
+
#
|
|
283
|
+
# @param abs_path [String] absolute file path to test
|
|
284
|
+
# @param patterns [Array<String>] absolute glob patterns
|
|
285
|
+
# @return [Boolean] true if the path matches at least one pattern
|
|
286
|
+
private def matches_any_pattern?(abs_path, patterns)
|
|
287
|
+
flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
288
|
+
patterns.any? { |pattern| File.fnmatch?(pattern, abs_path, flags) }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Retrieves coverage data for a file path.
|
|
292
|
+
# Converts the path to absolute form and performs staleness checking if enabled.
|
|
293
|
+
#
|
|
294
|
+
# @param path [String] relative or absolute file path
|
|
295
|
+
# @return [Array(String, Array)] tuple of [absolute_path, coverage_lines]
|
|
296
|
+
# @raise [FileError] if no coverage data exists for the file
|
|
297
|
+
# @raise [FileNotFoundError] if the file does not exist
|
|
298
|
+
# @raise [CoverageDataStaleError] if staleness checking is enabled and data is stale
|
|
299
|
+
private def coverage_data_for(path)
|
|
300
|
+
file_abs = File.absolute_path(path, @root)
|
|
301
|
+
begin
|
|
302
|
+
coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
|
|
303
|
+
rescue RuntimeError
|
|
304
|
+
raise FileError, "No coverage data found for file: #{path}"
|
|
305
|
+
end
|
|
306
|
+
@checker.check_file!(file_abs, coverage_lines) unless @checker.off?
|
|
307
|
+
if coverage_lines.nil?
|
|
308
|
+
raise FileError, "No coverage data found for file: #{path}"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
[file_abs, coverage_lines]
|
|
312
|
+
rescue Errno::ENOENT
|
|
313
|
+
raise FileNotFoundError, "File not found: #{path}"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
private def totals_from_rows(rows)
|
|
317
|
+
covered = rows.sum { |row| row['covered'].to_i }
|
|
318
|
+
total = rows.sum { |row| row['total'].to_i }
|
|
319
|
+
uncovered = total - covered
|
|
320
|
+
percentage = total.zero? ? 100.0 : ((covered.to_f * 100.0 / total) * 100).round / 100.0
|
|
321
|
+
stale_count = rows.count { |row| row['stale'] }
|
|
322
|
+
files_total = rows.length
|
|
323
|
+
|
|
324
|
+
{
|
|
325
|
+
'lines' => {
|
|
326
|
+
'covered' => covered,
|
|
327
|
+
'uncovered' => uncovered,
|
|
328
|
+
'total' => total
|
|
329
|
+
},
|
|
330
|
+
'percentage' => percentage,
|
|
331
|
+
'files' => {
|
|
332
|
+
'total' => files_total,
|
|
333
|
+
'ok' => files_total - stale_count,
|
|
334
|
+
'stale' => stale_count
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CovLoupe
|
|
4
|
+
# Shared normalization logic for CLI options.
|
|
5
|
+
# Provides both strict (raise on invalid) and lenient (default on invalid) modes.
|
|
6
|
+
module OptionNormalizers
|
|
7
|
+
SORT_ORDER_MAP = {
|
|
8
|
+
'a' => :ascending,
|
|
9
|
+
'ascending' => :ascending,
|
|
10
|
+
'd' => :descending,
|
|
11
|
+
'descending' => :descending
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
SOURCE_MODE_MAP = {
|
|
15
|
+
'f' => :full,
|
|
16
|
+
'full' => :full,
|
|
17
|
+
'u' => :uncovered,
|
|
18
|
+
'uncovered' => :uncovered
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
STALENESS_MAP = {
|
|
22
|
+
'o' => :off,
|
|
23
|
+
'off' => :off,
|
|
24
|
+
'e' => :error,
|
|
25
|
+
'error' => :error
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
ERROR_MODE_MAP = {
|
|
29
|
+
'off' => :off,
|
|
30
|
+
'o' => :off,
|
|
31
|
+
'log' => :log,
|
|
32
|
+
'l' => :log,
|
|
33
|
+
'debug' => :debug,
|
|
34
|
+
'd' => :debug
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
FORMAT_MAP = {
|
|
38
|
+
't' => :table,
|
|
39
|
+
'table' => :table,
|
|
40
|
+
'j' => :json,
|
|
41
|
+
'json' => :json,
|
|
42
|
+
'J' => :pretty_json,
|
|
43
|
+
'pretty_json' => :pretty_json,
|
|
44
|
+
'pretty-json' => :pretty_json,
|
|
45
|
+
'y' => :yaml,
|
|
46
|
+
'yaml' => :yaml,
|
|
47
|
+
'a' => :awesome_print,
|
|
48
|
+
'awesome_print' => :awesome_print,
|
|
49
|
+
'ap' => :awesome_print
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
module_function def normalize_sort_order(value, strict: true)
|
|
53
|
+
normalized = SORT_ORDER_MAP[value.to_s.downcase]
|
|
54
|
+
return normalized if normalized
|
|
55
|
+
raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
|
|
56
|
+
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Normalize source mode value.
|
|
61
|
+
# @param value [String, Symbol, nil] The value to normalize
|
|
62
|
+
# @param strict [Boolean] If true, raises on invalid value; if false, returns nil
|
|
63
|
+
# @return [Symbol, nil] The normalized symbol or nil if invalid and not strict
|
|
64
|
+
# @raise [OptionParser::InvalidArgument] If strict and value is invalid
|
|
65
|
+
module_function def normalize_source_mode(value, strict: true)
|
|
66
|
+
normalized = SOURCE_MODE_MAP[value.to_s.downcase]
|
|
67
|
+
return normalized if normalized
|
|
68
|
+
raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
|
|
69
|
+
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Normalize stale mode value.
|
|
74
|
+
# @param value [String, Symbol] The value to normalize
|
|
75
|
+
# @param strict [Boolean] If true, raises on invalid value; if false, returns nil
|
|
76
|
+
# @return [Symbol, nil] The normalized symbol or nil if invalid and not strict
|
|
77
|
+
# @raise [OptionParser::InvalidArgument] If strict and value is invalid
|
|
78
|
+
module_function def normalize_staleness(value, strict: true)
|
|
79
|
+
normalized = STALENESS_MAP[value.to_s.downcase]
|
|
80
|
+
return normalized if normalized
|
|
81
|
+
raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
|
|
82
|
+
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Normalize error mode value.
|
|
87
|
+
# @param value [String, Symbol, nil] The value to normalize
|
|
88
|
+
# @param strict [Boolean] If true, raises on invalid value; if false, returns default
|
|
89
|
+
# @param default [Symbol] The default value to return if invalid and not strict
|
|
90
|
+
# @return [Symbol] The normalized symbol or default if invalid and not strict
|
|
91
|
+
# @raise [OptionParser::InvalidArgument] If strict and value is invalid
|
|
92
|
+
module_function def normalize_error_mode(value, strict: true, default: :log)
|
|
93
|
+
normalized = ERROR_MODE_MAP[value.to_s.downcase]
|
|
94
|
+
return normalized if normalized
|
|
95
|
+
raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
|
|
96
|
+
|
|
97
|
+
default
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Normalize format value.
|
|
101
|
+
# @param value [String, Symbol] The value to normalize
|
|
102
|
+
# @param strict [Boolean] If true, raises on invalid value; if false, returns nil
|
|
103
|
+
# @return [Symbol, nil] The normalized symbol or nil if invalid and not strict
|
|
104
|
+
# @raise [OptionParser::InvalidArgument] If strict and value is invalid
|
|
105
|
+
module_function def normalize_format(value, strict: true)
|
|
106
|
+
normalized = FORMAT_MAP[value.to_s.downcase]
|
|
107
|
+
return normalized if normalized
|
|
108
|
+
raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
|
|
109
|
+
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'option_normalizers'
|
|
4
|
+
require_relative 'version'
|
|
5
|
+
|
|
6
|
+
module CovLoupe
|
|
7
|
+
class OptionParserBuilder
|
|
8
|
+
HORIZONTAL_RULE = '-' * 79
|
|
9
|
+
SUBCOMMANDS = %w[list summary raw uncovered detailed totals validate version].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :config
|
|
12
|
+
|
|
13
|
+
def initialize(config)
|
|
14
|
+
@config = config
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def build_option_parser
|
|
18
|
+
require 'optparse'
|
|
19
|
+
OptionParser.new do |parser|
|
|
20
|
+
configure_banner(parser)
|
|
21
|
+
define_subcommands_help(parser)
|
|
22
|
+
define_options(parser)
|
|
23
|
+
define_examples(parser)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private def configure_banner(parser)
|
|
28
|
+
parser.banner = <<~BANNER
|
|
29
|
+
#{HORIZONTAL_RULE}
|
|
30
|
+
Usage: cov-loupe [options] [subcommand] [args] (default subcommand: list)
|
|
31
|
+
Repository: https://github.com/keithrbennett/cov-loupe
|
|
32
|
+
Version: #{CovLoupe::VERSION}
|
|
33
|
+
#{HORIZONTAL_RULE}
|
|
34
|
+
|
|
35
|
+
BANNER
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private def define_subcommands_help(parser)
|
|
39
|
+
parser.separator <<~SUBCOMMANDS
|
|
40
|
+
Subcommands:
|
|
41
|
+
detailed <path> Show per-line rows with hits/covered
|
|
42
|
+
list Show files coverage (default: table, or use --format)
|
|
43
|
+
raw <path> Show the SimpleCov 'lines' array
|
|
44
|
+
summary <path> Show covered/total/% for a file
|
|
45
|
+
totals Show aggregated line totals and average %
|
|
46
|
+
uncovered <path> Show uncovered lines and a summary
|
|
47
|
+
validate <file> Evaluate coverage policy from file (exit 0=pass, 1=fail, 2=error)
|
|
48
|
+
validate -e <code> Evaluate coverage policy from code string
|
|
49
|
+
version Show version information
|
|
50
|
+
|
|
51
|
+
SUBCOMMANDS
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private def define_options(parser)
|
|
55
|
+
parser.separator 'Options:'
|
|
56
|
+
parser.on('-r', '--resultset PATH', String,
|
|
57
|
+
'Path or directory that contains .resultset.json (default: coverage/.resultset.json)') \
|
|
58
|
+
do |value|
|
|
59
|
+
config.resultset = value
|
|
60
|
+
end
|
|
61
|
+
parser.on('-R', '--root PATH', String, 'Project root (default: .)') do |value|
|
|
62
|
+
config.root = value
|
|
63
|
+
end
|
|
64
|
+
parser.on(
|
|
65
|
+
'-f', '--format FORMAT', String,
|
|
66
|
+
'Output format: t[able]|j[son]|pretty-json|y[aml]|a[wesome-print] (default: table)'
|
|
67
|
+
) do |value|
|
|
68
|
+
config.format = normalize_format(value)
|
|
69
|
+
end
|
|
70
|
+
parser.on('-o', '--sort-order ORDER', String,
|
|
71
|
+
'Sort order for list: a[scending]|d[escending] (default descending)') do |value|
|
|
72
|
+
config.sort_order = normalize_sort_order(value)
|
|
73
|
+
end
|
|
74
|
+
parser.on('-s', '--source MODE', String,
|
|
75
|
+
'Source display: f[ull]|u[ncovered]') do |value|
|
|
76
|
+
config.source_mode = normalize_source_mode(value)
|
|
77
|
+
end
|
|
78
|
+
parser.on('-c', '--context-lines N', Integer,
|
|
79
|
+
'Context lines around uncovered lines (non-negative, default: 2)') do |value|
|
|
80
|
+
config.source_context = value
|
|
81
|
+
end
|
|
82
|
+
parser.on('--color', 'Enable ANSI colors for source output') { config.color = true }
|
|
83
|
+
parser.on('--no-color', 'Disable ANSI colors') { config.color = false }
|
|
84
|
+
parser.on('-S', '--staleness MODE', String,
|
|
85
|
+
'Staleness detection: o[ff]|e[rror] (default off)') do |value|
|
|
86
|
+
config.staleness = normalize_staleness(value)
|
|
87
|
+
end
|
|
88
|
+
parser.on('-g', '--tracked-globs x,y,z', Array,
|
|
89
|
+
'Globs for filtering files (list/totals subcommands)') do |value|
|
|
90
|
+
config.tracked_globs = value
|
|
91
|
+
end
|
|
92
|
+
parser.on('-h', '--help', 'Show help') do
|
|
93
|
+
puts parser
|
|
94
|
+
gem_root = File.expand_path('../..', __dir__)
|
|
95
|
+
puts "\nFor more detailed help, consult README.md and docs/user/**/*.md"
|
|
96
|
+
puts "in the installed gem at: #{gem_root}"
|
|
97
|
+
exit 0
|
|
98
|
+
end
|
|
99
|
+
parser.on('-l', '--log-file PATH', String,
|
|
100
|
+
'Log file path (default ./cov_loupe.log, use stdout/stderr for streams)') do |value|
|
|
101
|
+
config.log_file = value
|
|
102
|
+
end
|
|
103
|
+
parser.on('--error-mode MODE', String,
|
|
104
|
+
'Error handling mode: o[ff]|l[og]|d[ebug] (default log). ' \
|
|
105
|
+
'off (silent), log (log errors to file), debug (verbose with backtraces)') do |value|
|
|
106
|
+
config.error_mode = normalize_error_mode(value)
|
|
107
|
+
end
|
|
108
|
+
parser.on('--force-cli', 'Force CLI mode (useful in scripts where auto-detection fails)') do
|
|
109
|
+
# This flag is mainly for mode detection - no action needed here
|
|
110
|
+
end
|
|
111
|
+
parser.on('-v', '--version', 'Show version information and exit') do
|
|
112
|
+
config.show_version = true
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private def define_examples(parser)
|
|
117
|
+
parser.separator <<~EXAMPLES
|
|
118
|
+
|
|
119
|
+
Examples:
|
|
120
|
+
cov-loupe --resultset coverage list
|
|
121
|
+
cov-loupe --format json --resultset coverage summary lib/foo.rb
|
|
122
|
+
cov-loupe --source uncovered --context-lines 2 uncovered lib/foo.rb
|
|
123
|
+
cov-loupe totals --format json
|
|
124
|
+
EXAMPLES
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private def normalize_sort_order(value)
|
|
128
|
+
OptionNormalizers.normalize_sort_order(value, strict: true)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private def normalize_source_mode(value)
|
|
132
|
+
OptionNormalizers.normalize_source_mode(value, strict: true)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private def normalize_staleness(value)
|
|
136
|
+
OptionNormalizers.normalize_staleness(value, strict: true)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private def normalize_error_mode(value)
|
|
140
|
+
OptionNormalizers.normalize_error_mode(value, strict: true)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private def normalize_format(value)
|
|
144
|
+
OptionNormalizers.normalize_format(value, strict: true)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|