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,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'app_config'
|
|
5
|
+
require_relative 'option_parser_builder'
|
|
6
|
+
require_relative 'commands/command_factory'
|
|
7
|
+
require_relative 'option_parsers/error_helper'
|
|
8
|
+
require_relative 'option_parsers/env_options_parser'
|
|
9
|
+
require_relative 'constants'
|
|
10
|
+
require_relative 'presenters/project_coverage_presenter'
|
|
11
|
+
|
|
12
|
+
module CovLoupe
|
|
13
|
+
class CoverageCLI
|
|
14
|
+
SUBCOMMANDS = %w[list summary raw uncovered detailed totals validate version].freeze
|
|
15
|
+
HORIZONTAL_RULE = '-' * 79
|
|
16
|
+
|
|
17
|
+
# Reference shared constant to avoid duplication with ModeDetector
|
|
18
|
+
OPTIONS_EXPECTING_ARGUMENT = Constants::OPTIONS_EXPECTING_ARGUMENT
|
|
19
|
+
|
|
20
|
+
attr_reader :config
|
|
21
|
+
|
|
22
|
+
# Initialize CLI for pure CLI usage only.
|
|
23
|
+
# Always runs as CLI, no mode detection needed.
|
|
24
|
+
def initialize(error_handler: nil)
|
|
25
|
+
@config = AppConfig.new
|
|
26
|
+
@cmd = nil
|
|
27
|
+
@cmd_args = []
|
|
28
|
+
@custom_error_handler = error_handler # Store custom handler if provided
|
|
29
|
+
@error_handler = nil # Will be created after parsing options
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def run(argv)
|
|
33
|
+
context = nil
|
|
34
|
+
# argv should already include environment options (merged by caller)
|
|
35
|
+
# Pre-scan for error-mode to ensure early errors are logged with correct verbosity
|
|
36
|
+
pre_scan_error_mode(argv)
|
|
37
|
+
parse_options!(argv)
|
|
38
|
+
enforce_version_subcommand_if_requested
|
|
39
|
+
|
|
40
|
+
context = CovLoupe.create_context(
|
|
41
|
+
error_handler: error_handler, # construct after options to respect --error-mode
|
|
42
|
+
log_target: config.log_file.nil? ? CovLoupe.context.log_target : config.log_file,
|
|
43
|
+
mode: :cli
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
CovLoupe.with_context(context) do
|
|
47
|
+
if @cmd
|
|
48
|
+
run_subcommand(@cmd, @cmd_args)
|
|
49
|
+
else
|
|
50
|
+
show_default_report(sort_order: config.sort_order)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
rescue OptionParser::ParseError => e
|
|
54
|
+
# Handle any option parsing errors (invalid option/argument) without relying on
|
|
55
|
+
# @error_handler, which is not guaranteed to be initialized yet.
|
|
56
|
+
with_context_if_available(context) { handle_option_parser_error(e, argv: argv) }
|
|
57
|
+
rescue CovLoupe::Error => e
|
|
58
|
+
with_context_if_available(context) { handle_user_facing_error(e) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def show_default_report(sort_order: :descending, output: $stdout)
|
|
62
|
+
model = CoverageModel.new(**config.model_options)
|
|
63
|
+
presenter = Presenters::ProjectCoveragePresenter.new(
|
|
64
|
+
model: model,
|
|
65
|
+
sort_order: sort_order,
|
|
66
|
+
check_stale: (config.staleness == :error),
|
|
67
|
+
tracked_globs: config.tracked_globs
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if config.format != :table
|
|
71
|
+
require_relative 'formatters'
|
|
72
|
+
output.puts Formatters.format(presenter.relativized_payload, config.format)
|
|
73
|
+
return
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
file_summaries = presenter.relative_files
|
|
77
|
+
output.puts model.format_table(
|
|
78
|
+
file_summaries,
|
|
79
|
+
sort_order: sort_order,
|
|
80
|
+
check_stale: (config.staleness == :error),
|
|
81
|
+
tracked_globs: nil
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private def parse_options!(argv)
|
|
86
|
+
require 'optparse'
|
|
87
|
+
parser = build_option_parser
|
|
88
|
+
|
|
89
|
+
# order! parses global options (updating config) and removes them from argv.
|
|
90
|
+
# It stops cleanly at the first subcommand (e.g., 'list', 'summary') or unknown option.
|
|
91
|
+
# If it stops at an unknown option, it raises OptionParser::InvalidOption.
|
|
92
|
+
parser.order!(argv)
|
|
93
|
+
|
|
94
|
+
# The first remaining argument is the subcommand
|
|
95
|
+
@cmd = argv.shift
|
|
96
|
+
|
|
97
|
+
# Verify it's a valid subcommand if present
|
|
98
|
+
if @cmd && !SUBCOMMANDS.include?(@cmd)
|
|
99
|
+
raise UsageError, "Unknown subcommand: '#{@cmd}'. Valid subcommands: #{SUBCOMMANDS.join(', ')}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Any remaining arguments belong to the subcommand
|
|
103
|
+
@cmd_args = argv
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private def error_handler
|
|
107
|
+
@error_handler ||= @custom_error_handler ||
|
|
108
|
+
ErrorHandlerFactory.for_cli(error_mode: config.error_mode)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private def pre_scan_error_mode(argv)
|
|
112
|
+
env_parser = OptionParsers::EnvOptionsParser.new
|
|
113
|
+
config.error_mode = env_parser.pre_scan_error_mode(argv) || :log
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private def build_option_parser
|
|
117
|
+
builder = OptionParserBuilder.new(config)
|
|
118
|
+
builder.build_option_parser
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Converts the -v/--version flags into the version subcommand.
|
|
122
|
+
# When the user passes -v or --version, config.show_version is set to true during option parsing.
|
|
123
|
+
# This method intercepts that flag and redirects execution to the 'version' subcommand,
|
|
124
|
+
# ensuring consistent version display regardless of whether the user runs
|
|
125
|
+
# `cov-loupe -v`, `cov-loupe --version`, or `cov-loupe version`.
|
|
126
|
+
private def enforce_version_subcommand_if_requested
|
|
127
|
+
@cmd = 'version' if config.show_version
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private def with_context_if_available(ctx, &block)
|
|
131
|
+
if ctx
|
|
132
|
+
CovLoupe.with_context(ctx, &block)
|
|
133
|
+
else
|
|
134
|
+
block.call
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private def run_subcommand(cmd, args)
|
|
139
|
+
# Check if user mistakenly placed global options after the subcommand
|
|
140
|
+
check_for_misplaced_global_options(cmd, args)
|
|
141
|
+
|
|
142
|
+
command = Commands::CommandFactory.create(cmd, self)
|
|
143
|
+
command.execute(args)
|
|
144
|
+
rescue CovLoupe::Error => e
|
|
145
|
+
handle_user_facing_error(e)
|
|
146
|
+
rescue => e
|
|
147
|
+
error_handler.handle_error(e, context: "subcommand '#{cmd}'")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private def handle_option_parser_error(error, argv: [])
|
|
151
|
+
@error_helper ||= OptionParsers::ErrorHelper.new(SUBCOMMANDS)
|
|
152
|
+
@error_helper.handle_option_parser_error(error, argv: argv)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private def check_for_misplaced_global_options(cmd, args)
|
|
156
|
+
# Global options that users commonly place after subcommands by mistake
|
|
157
|
+
global_options = %w[-r --resultset -R --root -f --format -o --sort-order -s --source
|
|
158
|
+
-c --context-lines -S --staleness -g --tracked-globs
|
|
159
|
+
-l --log-file --error-mode --color --no-color]
|
|
160
|
+
|
|
161
|
+
misplaced = args.select { |arg| global_options.include?(arg) }
|
|
162
|
+
return if misplaced.empty?
|
|
163
|
+
|
|
164
|
+
option_list = misplaced.join(', ')
|
|
165
|
+
raise UsageError, "Global option(s) must come BEFORE the subcommand.\n" \
|
|
166
|
+
"You used: #{cmd} #{option_list}\n" \
|
|
167
|
+
"Correct: #{option_list} #{cmd}\n\n" \
|
|
168
|
+
"Example: cov-loupe --format json #{cmd}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private def handle_user_facing_error(error)
|
|
172
|
+
error_handler.handle_error(error, context: 'CLI', reraise: false)
|
|
173
|
+
warn error.user_friendly_message
|
|
174
|
+
warn error.backtrace.first(5).join("\n") if config.error_mode == :debug && error.backtrace
|
|
175
|
+
exit 1
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../formatters'
|
|
5
|
+
require_relative '../formatters/source_formatter'
|
|
6
|
+
require_relative '../model'
|
|
7
|
+
require_relative '../errors'
|
|
8
|
+
|
|
9
|
+
module CovLoupe
|
|
10
|
+
module Commands
|
|
11
|
+
class BaseCommand
|
|
12
|
+
def initialize(cli_context)
|
|
13
|
+
@cli = cli_context
|
|
14
|
+
@config = cli_context.config
|
|
15
|
+
@source_formatter = Formatters::SourceFormatter.new(
|
|
16
|
+
**config.formatter_options
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr_reader :cli, :config, :source_formatter
|
|
21
|
+
|
|
22
|
+
protected def model
|
|
23
|
+
@model ||= CoverageModel.new(**config.model_options)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
protected def handle_with_path(args, name)
|
|
27
|
+
path = args.shift or raise UsageError.for_subcommand("#{name} <path>")
|
|
28
|
+
yield(path)
|
|
29
|
+
rescue Errno::ENOENT
|
|
30
|
+
raise FileNotFoundError, "File not found: #{path}"
|
|
31
|
+
rescue Errno::EACCES
|
|
32
|
+
raise FilePermissionError, "Permission denied: #{path}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
protected def maybe_output_structured_format?(obj, model)
|
|
36
|
+
return false if config.format == :table
|
|
37
|
+
|
|
38
|
+
puts CovLoupe::Formatters.format(model.relativize(obj), config.format)
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
protected def emit_structured_format_with_optional_source?(data, model, path)
|
|
43
|
+
return false if config.format == :table
|
|
44
|
+
|
|
45
|
+
relativized = model.relativize(data)
|
|
46
|
+
if config.source_mode
|
|
47
|
+
payload = relativized.merge('source' => build_source_payload(model, path))
|
|
48
|
+
puts CovLoupe::Formatters.format(payload, config.format)
|
|
49
|
+
else
|
|
50
|
+
puts CovLoupe::Formatters.format(relativized, config.format)
|
|
51
|
+
end
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
protected def build_source_payload(model, path)
|
|
56
|
+
source_formatter.build_source_payload(model, path, mode: config.source_mode,
|
|
57
|
+
context: config.source_context)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
protected def print_source_for(model, path)
|
|
61
|
+
formatted = source_formatter.format_source_for(model, path, mode: config.source_mode,
|
|
62
|
+
context: config.source_context)
|
|
63
|
+
puts formatted
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_command'
|
|
4
|
+
require_relative 'list_command'
|
|
5
|
+
require_relative 'version_command'
|
|
6
|
+
require_relative 'summary_command'
|
|
7
|
+
require_relative 'raw_command'
|
|
8
|
+
require_relative 'uncovered_command'
|
|
9
|
+
require_relative 'detailed_command'
|
|
10
|
+
require_relative 'totals_command'
|
|
11
|
+
require_relative 'validate_command'
|
|
12
|
+
|
|
13
|
+
module CovLoupe
|
|
14
|
+
module Commands
|
|
15
|
+
class CommandFactory
|
|
16
|
+
COMMAND_MAP = {
|
|
17
|
+
'list' => ListCommand,
|
|
18
|
+
'version' => VersionCommand,
|
|
19
|
+
'summary' => SummaryCommand,
|
|
20
|
+
'raw' => RawCommand,
|
|
21
|
+
'uncovered' => UncoveredCommand,
|
|
22
|
+
'detailed' => DetailedCommand,
|
|
23
|
+
'totals' => TotalsCommand,
|
|
24
|
+
'total' => TotalsCommand, # Alias for backward compatibility
|
|
25
|
+
'validate' => ValidateCommand
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
def self.create(command_name, cli_context)
|
|
29
|
+
command_class = COMMAND_MAP[command_name]
|
|
30
|
+
unless command_class
|
|
31
|
+
raise UsageError.for_subcommand(
|
|
32
|
+
'list | summary <path> | raw <path> | uncovered <path> | detailed <path> ' \
|
|
33
|
+
'| totals | validate <file> | validate -i <code> | version'
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
command_class.new(cli_context)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.available_commands
|
|
41
|
+
COMMAND_MAP.keys
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_command'
|
|
4
|
+
require_relative '../formatters/source_formatter'
|
|
5
|
+
require_relative '../presenters/coverage_detailed_presenter'
|
|
6
|
+
require_relative '../table_formatter'
|
|
7
|
+
|
|
8
|
+
module CovLoupe
|
|
9
|
+
module Commands
|
|
10
|
+
class DetailedCommand < BaseCommand
|
|
11
|
+
def execute(args)
|
|
12
|
+
handle_with_path(args, 'detailed') do |path|
|
|
13
|
+
presenter = Presenters::CoverageDetailedPresenter.new(model: model, path: path)
|
|
14
|
+
data = presenter.absolute_payload
|
|
15
|
+
break if emit_structured_format_with_optional_source?(data, model, path)
|
|
16
|
+
|
|
17
|
+
relative_path = presenter.relative_path
|
|
18
|
+
puts "File: #{relative_path}"
|
|
19
|
+
puts
|
|
20
|
+
|
|
21
|
+
# Table format with box-drawing
|
|
22
|
+
headers = ['Line', 'Hits', 'Covered']
|
|
23
|
+
rows = data['lines'].map do |r|
|
|
24
|
+
[r['line'].to_s, r['hits'].to_s, r['covered'] ? 'yes' : 'no']
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
puts TableFormatter.format(
|
|
28
|
+
headers: headers,
|
|
29
|
+
rows: rows,
|
|
30
|
+
alignments: [:right, :right, :center]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
print_source_for(model, path) if config.source_mode
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_command'
|
|
4
|
+
require_relative '../presenters/coverage_raw_presenter'
|
|
5
|
+
require_relative '../table_formatter'
|
|
6
|
+
|
|
7
|
+
module CovLoupe
|
|
8
|
+
module Commands
|
|
9
|
+
class RawCommand < BaseCommand
|
|
10
|
+
def execute(args)
|
|
11
|
+
handle_with_path(args, 'raw') do |path|
|
|
12
|
+
presenter = Presenters::CoverageRawPresenter.new(model: model, path: path)
|
|
13
|
+
data = presenter.absolute_payload
|
|
14
|
+
break if maybe_output_structured_format?(data, model)
|
|
15
|
+
|
|
16
|
+
relative_path = presenter.relative_path
|
|
17
|
+
puts "File: #{relative_path}"
|
|
18
|
+
puts
|
|
19
|
+
|
|
20
|
+
# Table format for raw coverage data
|
|
21
|
+
headers = ['Line', 'Coverage']
|
|
22
|
+
rows = data['lines'].each_with_index.map do |coverage, index|
|
|
23
|
+
[
|
|
24
|
+
(index + 1).to_s,
|
|
25
|
+
coverage.nil? ? 'nil' : coverage.to_s
|
|
26
|
+
]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
puts TableFormatter.format(
|
|
30
|
+
headers: headers,
|
|
31
|
+
rows: rows,
|
|
32
|
+
alignments: [:right, :right]
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_command'
|
|
4
|
+
require_relative '../presenters/coverage_summary_presenter'
|
|
5
|
+
require_relative '../table_formatter'
|
|
6
|
+
|
|
7
|
+
module CovLoupe
|
|
8
|
+
module Commands
|
|
9
|
+
class SummaryCommand < BaseCommand
|
|
10
|
+
def execute(args)
|
|
11
|
+
handle_with_path(args, 'summary') do |path|
|
|
12
|
+
presenter = Presenters::CoverageSummaryPresenter.new(model: model, path: path)
|
|
13
|
+
data = presenter.absolute_payload
|
|
14
|
+
break if emit_structured_format_with_optional_source?(data, model, path)
|
|
15
|
+
|
|
16
|
+
relative_path = presenter.relative_path
|
|
17
|
+
summary = data['summary']
|
|
18
|
+
|
|
19
|
+
# Table format with box-drawing
|
|
20
|
+
headers = ['File', '%', 'Covered', 'Total', 'Stale']
|
|
21
|
+
stale_marker = data['stale'] ? 'Yes' : ''
|
|
22
|
+
rows = [[
|
|
23
|
+
relative_path,
|
|
24
|
+
format('%.2f%%', summary['percentage']),
|
|
25
|
+
summary['covered'].to_s,
|
|
26
|
+
summary['total'].to_s,
|
|
27
|
+
stale_marker
|
|
28
|
+
]]
|
|
29
|
+
|
|
30
|
+
puts TableFormatter.format(
|
|
31
|
+
headers: headers,
|
|
32
|
+
rows: rows,
|
|
33
|
+
alignments: [:left, :right, :right, :right, :center]
|
|
34
|
+
)
|
|
35
|
+
puts
|
|
36
|
+
print_source_for(model, path) if config.source_mode
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_command'
|
|
4
|
+
require_relative '../presenters/project_totals_presenter'
|
|
5
|
+
require_relative '../table_formatter'
|
|
6
|
+
|
|
7
|
+
module CovLoupe
|
|
8
|
+
module Commands
|
|
9
|
+
class TotalsCommand < BaseCommand
|
|
10
|
+
def execute(args)
|
|
11
|
+
unless args.empty?
|
|
12
|
+
raise UsageError.for_subcommand('totals')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
presenter = Presenters::ProjectTotalsPresenter.new(
|
|
16
|
+
model: model,
|
|
17
|
+
check_stale: (config.staleness == :error),
|
|
18
|
+
tracked_globs: config.tracked_globs
|
|
19
|
+
)
|
|
20
|
+
payload = presenter.absolute_payload
|
|
21
|
+
return if maybe_output_structured_format?(payload, model)
|
|
22
|
+
|
|
23
|
+
lines = payload['lines']
|
|
24
|
+
files = payload['files']
|
|
25
|
+
|
|
26
|
+
# Table format
|
|
27
|
+
headers = ['Metric', 'Total', 'Covered', 'Uncovered', '%']
|
|
28
|
+
rows = [
|
|
29
|
+
[
|
|
30
|
+
'Lines',
|
|
31
|
+
lines['total'].to_s,
|
|
32
|
+
lines['covered'].to_s,
|
|
33
|
+
lines['uncovered'].to_s,
|
|
34
|
+
format('%.2f%%', payload['percentage'])
|
|
35
|
+
],
|
|
36
|
+
[
|
|
37
|
+
'Files',
|
|
38
|
+
files['total'].to_s,
|
|
39
|
+
files['ok'].to_s,
|
|
40
|
+
files['stale'].to_s,
|
|
41
|
+
''
|
|
42
|
+
]
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
puts TableFormatter.format(
|
|
46
|
+
headers: headers,
|
|
47
|
+
rows: rows,
|
|
48
|
+
alignments: [:left, :right, :right, :right, :right]
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_command'
|
|
4
|
+
require_relative '../presenters/coverage_uncovered_presenter'
|
|
5
|
+
require_relative '../table_formatter'
|
|
6
|
+
|
|
7
|
+
module CovLoupe
|
|
8
|
+
module Commands
|
|
9
|
+
class UncoveredCommand < BaseCommand
|
|
10
|
+
def execute(args)
|
|
11
|
+
handle_with_path(args, 'uncovered') do |path|
|
|
12
|
+
presenter = Presenters::CoverageUncoveredPresenter.new(model: model, path: path)
|
|
13
|
+
data = presenter.absolute_payload
|
|
14
|
+
break if emit_structured_format_with_optional_source?(data, model, path)
|
|
15
|
+
|
|
16
|
+
relative_path = presenter.relative_path
|
|
17
|
+
summary = data['summary']
|
|
18
|
+
|
|
19
|
+
puts "File: #{relative_path}"
|
|
20
|
+
puts "Coverage: #{format('%.2f%%', summary['percentage'])} " \
|
|
21
|
+
"(#{summary['covered']}/#{summary['total']} lines)"
|
|
22
|
+
puts
|
|
23
|
+
|
|
24
|
+
# Table format for uncovered lines
|
|
25
|
+
uncovered_lines = data['uncovered']
|
|
26
|
+
if uncovered_lines.empty?
|
|
27
|
+
puts 'All lines covered!'
|
|
28
|
+
else
|
|
29
|
+
headers = ['Line']
|
|
30
|
+
rows = uncovered_lines.map { |line| [line.to_s] }
|
|
31
|
+
|
|
32
|
+
puts TableFormatter.format(
|
|
33
|
+
headers: headers,
|
|
34
|
+
rows: rows,
|
|
35
|
+
alignments: [:right]
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
puts
|
|
40
|
+
print_source_for(model, path) if config.source_mode
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_command'
|
|
4
|
+
require_relative '../predicate_evaluator'
|
|
5
|
+
|
|
6
|
+
module CovLoupe
|
|
7
|
+
module Commands
|
|
8
|
+
# Validates coverage data against a predicate.
|
|
9
|
+
# Exits with code 0 (pass), 1 (fail), or 2 (error).
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# cov-loupe validate policy.rb # File mode
|
|
13
|
+
# cov-loupe validate -i '->(m) { ... }' # Inline mode
|
|
14
|
+
class ValidateCommand < BaseCommand
|
|
15
|
+
def execute(args)
|
|
16
|
+
# Parse command-specific options
|
|
17
|
+
inline_mode = false
|
|
18
|
+
code = nil
|
|
19
|
+
|
|
20
|
+
# Simple option parsing for -i/--inline flag
|
|
21
|
+
while args.first&.start_with?('-')
|
|
22
|
+
case args.first
|
|
23
|
+
when '-i', '--inline'
|
|
24
|
+
inline_mode = true
|
|
25
|
+
args.shift
|
|
26
|
+
code = args.shift or raise UsageError.for_subcommand('validate -i <code>')
|
|
27
|
+
else
|
|
28
|
+
raise UsageError, "Unknown option for validate: #{args.first}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# If not inline mode, expect a file path as positional argument
|
|
33
|
+
unless inline_mode
|
|
34
|
+
file_path = args.shift or raise UsageError.for_subcommand('validate <file> | -i <code>')
|
|
35
|
+
code = file_path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Evaluate the predicate
|
|
39
|
+
result = if inline_mode
|
|
40
|
+
PredicateEvaluator.evaluate_code(code, model)
|
|
41
|
+
else
|
|
42
|
+
PredicateEvaluator.evaluate_file(code, model)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
exit(result ? 0 : 1)
|
|
46
|
+
rescue UsageError
|
|
47
|
+
# Usage errors should exit with code 1, not 2
|
|
48
|
+
raise
|
|
49
|
+
rescue => e
|
|
50
|
+
handle_predicate_error(e)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private def handle_predicate_error(error)
|
|
54
|
+
warn "Predicate error: #{error.message}"
|
|
55
|
+
warn error.backtrace.first(5).join("\n") if config.error_mode == :debug
|
|
56
|
+
exit 2
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'base_command'
|
|
5
|
+
require_relative '../table_formatter'
|
|
6
|
+
|
|
7
|
+
module CovLoupe
|
|
8
|
+
module Commands
|
|
9
|
+
class VersionCommand < BaseCommand
|
|
10
|
+
def execute(_args)
|
|
11
|
+
@gem_root = File.expand_path('../../..', __dir__)
|
|
12
|
+
|
|
13
|
+
if config.format == :table
|
|
14
|
+
data = {
|
|
15
|
+
'Version' => CovLoupe::VERSION,
|
|
16
|
+
'Gem Root' => @gem_root,
|
|
17
|
+
'Documentation' => 'README.md and docs/user/**/*.md in gem root'
|
|
18
|
+
}
|
|
19
|
+
puts TableFormatter.format_vertical(data)
|
|
20
|
+
else
|
|
21
|
+
puts CovLoupe::Formatters.format(version_info, config.format)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private def version_info
|
|
26
|
+
{
|
|
27
|
+
version: CovLoupe::VERSION,
|
|
28
|
+
gem_root: @gem_root
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'app_config'
|
|
4
|
+
require_relative 'option_parser_builder'
|
|
5
|
+
|
|
6
|
+
module CovLoupe
|
|
7
|
+
# Centralized configuration parser for both CLI and MCP modes
|
|
8
|
+
# Parses argv (which should already include environment options merged by caller)
|
|
9
|
+
class ConfigParser
|
|
10
|
+
attr_reader :config, :argv
|
|
11
|
+
|
|
12
|
+
def initialize(argv)
|
|
13
|
+
@argv = argv
|
|
14
|
+
@config = AppConfig.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Parse argv (with env opts already merged) and return config
|
|
18
|
+
# @param argv [Array<String>] command-line arguments (should include env opts if needed)
|
|
19
|
+
# @return [AppConfig] populated configuration object
|
|
20
|
+
def self.parse(argv)
|
|
21
|
+
new(argv).parse
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def parse
|
|
25
|
+
# Build and execute the option parser
|
|
26
|
+
parser = OptionParserBuilder.new(config).build_option_parser
|
|
27
|
+
parser.parse!(argv)
|
|
28
|
+
|
|
29
|
+
config
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CovLoupe
|
|
4
|
+
# Shared constants used across multiple components to avoid duplication.
|
|
5
|
+
# This ensures consistency between CLI option parsing and mode detection.
|
|
6
|
+
module Constants
|
|
7
|
+
# CLI options that expect an argument value following them.
|
|
8
|
+
# Used by both CoverageCLI and ModeDetector to correctly parse command-line arguments.
|
|
9
|
+
OPTIONS_EXPECTING_ARGUMENT = %w[
|
|
10
|
+
-r --resultset
|
|
11
|
+
-R --root
|
|
12
|
+
-f --format
|
|
13
|
+
-o --sort-order
|
|
14
|
+
-s --source
|
|
15
|
+
-c --context-lines
|
|
16
|
+
-S --staleness
|
|
17
|
+
-g --tracked-globs
|
|
18
|
+
-l --log-file
|
|
19
|
+
--error-mode
|
|
20
|
+
].freeze
|
|
21
|
+
end
|
|
22
|
+
end
|