cov-loupe 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +329 -0
  4. data/docs/dev/ARCHITECTURE.md +80 -0
  5. data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
  6. data/docs/dev/DEVELOPMENT.md +83 -0
  7. data/docs/dev/README.md +10 -0
  8. data/docs/dev/RELEASING.md +146 -0
  9. data/docs/dev/arch-decisions/001-x-arch-decision.md +95 -0
  10. data/docs/dev/arch-decisions/002-x-arch-decision.md +159 -0
  11. data/docs/dev/arch-decisions/003-x-arch-decision.md +165 -0
  12. data/docs/dev/arch-decisions/004-x-arch-decision.md +203 -0
  13. data/docs/dev/arch-decisions/005-x-arch-decision.md +189 -0
  14. data/docs/dev/arch-decisions/README.md +60 -0
  15. data/docs/dev/presentations/cov-loupe-presentation.md +255 -0
  16. data/docs/fixtures/demo_project/README.md +9 -0
  17. data/docs/user/ADVANCED_USAGE.md +777 -0
  18. data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
  19. data/docs/user/CLI_USAGE.md +750 -0
  20. data/docs/user/ERROR_HANDLING.md +93 -0
  21. data/docs/user/EXAMPLES.md +588 -0
  22. data/docs/user/INSTALLATION.md +130 -0
  23. data/docs/user/LIBRARY_API.md +693 -0
  24. data/docs/user/MCP_INTEGRATION.md +490 -0
  25. data/docs/user/README.md +14 -0
  26. data/docs/user/TROUBLESHOOTING.md +197 -0
  27. data/docs/user/V2-BREAKING-CHANGES.md +472 -0
  28. data/exe/cov-loupe +23 -0
  29. data/lib/cov_loupe/app_config.rb +56 -0
  30. data/lib/cov_loupe/app_context.rb +26 -0
  31. data/lib/cov_loupe/base_tool.rb +102 -0
  32. data/lib/cov_loupe/cli.rb +178 -0
  33. data/lib/cov_loupe/commands/base_command.rb +67 -0
  34. data/lib/cov_loupe/commands/command_factory.rb +45 -0
  35. data/lib/cov_loupe/commands/detailed_command.rb +38 -0
  36. data/lib/cov_loupe/commands/list_command.rb +13 -0
  37. data/lib/cov_loupe/commands/raw_command.rb +38 -0
  38. data/lib/cov_loupe/commands/summary_command.rb +41 -0
  39. data/lib/cov_loupe/commands/totals_command.rb +53 -0
  40. data/lib/cov_loupe/commands/uncovered_command.rb +45 -0
  41. data/lib/cov_loupe/commands/validate_command.rb +60 -0
  42. data/lib/cov_loupe/commands/version_command.rb +33 -0
  43. data/lib/cov_loupe/config_parser.rb +32 -0
  44. data/lib/cov_loupe/constants.rb +22 -0
  45. data/lib/cov_loupe/coverage_reporter.rb +31 -0
  46. data/lib/cov_loupe/error_handler.rb +165 -0
  47. data/lib/cov_loupe/error_handler_factory.rb +31 -0
  48. data/lib/cov_loupe/errors.rb +191 -0
  49. data/lib/cov_loupe/formatters/source_formatter.rb +152 -0
  50. data/lib/cov_loupe/formatters.rb +51 -0
  51. data/lib/cov_loupe/mcp_server.rb +42 -0
  52. data/lib/cov_loupe/mode_detector.rb +56 -0
  53. data/lib/cov_loupe/model.rb +339 -0
  54. data/lib/cov_loupe/option_normalizers.rb +113 -0
  55. data/lib/cov_loupe/option_parser_builder.rb +147 -0
  56. data/lib/cov_loupe/option_parsers/env_options_parser.rb +48 -0
  57. data/lib/cov_loupe/option_parsers/error_helper.rb +110 -0
  58. data/lib/cov_loupe/path_relativizer.rb +64 -0
  59. data/lib/cov_loupe/predicate_evaluator.rb +72 -0
  60. data/lib/cov_loupe/presenters/base_coverage_presenter.rb +42 -0
  61. data/lib/cov_loupe/presenters/coverage_detailed_presenter.rb +14 -0
  62. data/lib/cov_loupe/presenters/coverage_raw_presenter.rb +14 -0
  63. data/lib/cov_loupe/presenters/coverage_summary_presenter.rb +14 -0
  64. data/lib/cov_loupe/presenters/coverage_uncovered_presenter.rb +14 -0
  65. data/lib/cov_loupe/presenters/project_coverage_presenter.rb +50 -0
  66. data/lib/cov_loupe/presenters/project_totals_presenter.rb +27 -0
  67. data/lib/cov_loupe/resolvers/coverage_line_resolver.rb +122 -0
  68. data/lib/cov_loupe/resolvers/resolver_factory.rb +28 -0
  69. data/lib/cov_loupe/resolvers/resultset_path_resolver.rb +76 -0
  70. data/lib/cov_loupe/resultset_loader.rb +131 -0
  71. data/lib/cov_loupe/staleness_checker.rb +247 -0
  72. data/lib/cov_loupe/table_formatter.rb +64 -0
  73. data/lib/cov_loupe/tools/all_files_coverage_tool.rb +51 -0
  74. data/lib/cov_loupe/tools/coverage_detailed_tool.rb +35 -0
  75. data/lib/cov_loupe/tools/coverage_raw_tool.rb +34 -0
  76. data/lib/cov_loupe/tools/coverage_summary_tool.rb +34 -0
  77. data/lib/cov_loupe/tools/coverage_table_tool.rb +50 -0
  78. data/lib/cov_loupe/tools/coverage_totals_tool.rb +44 -0
  79. data/lib/cov_loupe/tools/help_tool.rb +115 -0
  80. data/lib/cov_loupe/tools/uncovered_lines_tool.rb +34 -0
  81. data/lib/cov_loupe/tools/validate_tool.rb +72 -0
  82. data/lib/cov_loupe/tools/version_tool.rb +32 -0
  83. data/lib/cov_loupe/util.rb +88 -0
  84. data/lib/cov_loupe/version.rb +5 -0
  85. data/lib/cov_loupe.rb +140 -0
  86. data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
  87. data/spec/TIMESTAMPS.md +48 -0
  88. data/spec/all_files_coverage_tool_spec.rb +53 -0
  89. data/spec/app_config_spec.rb +142 -0
  90. data/spec/base_tool_spec.rb +62 -0
  91. data/spec/cli/show_default_report_spec.rb +33 -0
  92. data/spec/cli_enumerated_options_spec.rb +90 -0
  93. data/spec/cli_error_spec.rb +184 -0
  94. data/spec/cli_format_spec.rb +123 -0
  95. data/spec/cli_json_options_spec.rb +50 -0
  96. data/spec/cli_source_spec.rb +44 -0
  97. data/spec/cli_spec.rb +192 -0
  98. data/spec/cli_table_spec.rb +28 -0
  99. data/spec/cli_usage_spec.rb +42 -0
  100. data/spec/commands/base_command_spec.rb +107 -0
  101. data/spec/commands/command_factory_spec.rb +76 -0
  102. data/spec/commands/detailed_command_spec.rb +34 -0
  103. data/spec/commands/list_command_spec.rb +28 -0
  104. data/spec/commands/raw_command_spec.rb +69 -0
  105. data/spec/commands/summary_command_spec.rb +34 -0
  106. data/spec/commands/totals_command_spec.rb +34 -0
  107. data/spec/commands/uncovered_command_spec.rb +55 -0
  108. data/spec/commands/validate_command_spec.rb +213 -0
  109. data/spec/commands/version_command_spec.rb +38 -0
  110. data/spec/constants_spec.rb +61 -0
  111. data/spec/cov_loupe/formatters/source_formatter_spec.rb +267 -0
  112. data/spec/cov_loupe/formatters_spec.rb +76 -0
  113. data/spec/cov_loupe/presenters/base_coverage_presenter_spec.rb +79 -0
  114. data/spec/cov_loupe_model_spec.rb +454 -0
  115. data/spec/cov_loupe_module_spec.rb +37 -0
  116. data/spec/cov_loupe_opts_spec.rb +185 -0
  117. data/spec/coverage_reporter_spec.rb +102 -0
  118. data/spec/coverage_table_tool_spec.rb +59 -0
  119. data/spec/coverage_totals_tool_spec.rb +37 -0
  120. data/spec/error_handler_spec.rb +197 -0
  121. data/spec/error_mode_spec.rb +139 -0
  122. data/spec/errors_edge_cases_spec.rb +312 -0
  123. data/spec/errors_stale_spec.rb +83 -0
  124. data/spec/file_based_mcp_tools_spec.rb +99 -0
  125. data/spec/fixtures/project1/lib/bar.rb +5 -0
  126. data/spec/fixtures/project1/lib/foo.rb +6 -0
  127. data/spec/help_tool_spec.rb +26 -0
  128. data/spec/integration_spec.rb +789 -0
  129. data/spec/logging_fallback_spec.rb +128 -0
  130. data/spec/mcp_logging_spec.rb +44 -0
  131. data/spec/mcp_server_integration_spec.rb +23 -0
  132. data/spec/mcp_server_spec.rb +106 -0
  133. data/spec/mode_detector_spec.rb +153 -0
  134. data/spec/model_error_handling_spec.rb +269 -0
  135. data/spec/model_staleness_spec.rb +79 -0
  136. data/spec/option_normalizers_spec.rb +203 -0
  137. data/spec/option_parsers/env_options_parser_spec.rb +221 -0
  138. data/spec/option_parsers/error_helper_spec.rb +222 -0
  139. data/spec/path_relativizer_spec.rb +98 -0
  140. data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
  141. data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
  142. data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
  143. data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
  144. data/spec/presenters/project_coverage_presenter_spec.rb +87 -0
  145. data/spec/presenters/project_totals_presenter_spec.rb +144 -0
  146. data/spec/resolvers/coverage_line_resolver_spec.rb +282 -0
  147. data/spec/resolvers/resolver_factory_spec.rb +61 -0
  148. data/spec/resolvers/resultset_path_resolver_spec.rb +60 -0
  149. data/spec/resultset_loader_spec.rb +167 -0
  150. data/spec/shared_examples/README.md +115 -0
  151. data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
  152. data/spec/shared_examples/file_based_mcp_tools.rb +179 -0
  153. data/spec/shared_examples/formatted_command_examples.rb +64 -0
  154. data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
  155. data/spec/spec_helper.rb +127 -0
  156. data/spec/staleness_checker_spec.rb +374 -0
  157. data/spec/staleness_more_spec.rb +42 -0
  158. data/spec/support/cli_helpers.rb +22 -0
  159. data/spec/support/control_flow_helpers.rb +20 -0
  160. data/spec/support/fake_mcp.rb +40 -0
  161. data/spec/support/io_helpers.rb +29 -0
  162. data/spec/support/mcp_helpers.rb +35 -0
  163. data/spec/support/mcp_runner.rb +66 -0
  164. data/spec/support/mocking_helpers.rb +30 -0
  165. data/spec/table_format_spec.rb +70 -0
  166. data/spec/tools/validate_tool_spec.rb +132 -0
  167. data/spec/tools_error_handling_spec.rb +130 -0
  168. data/spec/util_spec.rb +154 -0
  169. data/spec/version_spec.rb +123 -0
  170. data/spec/version_tool_spec.rb +141 -0
  171. metadata +290 -0
@@ -0,0 +1,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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_command'
4
+
5
+ module CovLoupe
6
+ module Commands
7
+ class ListCommand < BaseCommand
8
+ def execute(_args)
9
+ cli.send(:show_default_report, sort_order: config.sort_order)
10
+ end
11
+ end
12
+ end
13
+ 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