simplecov-mcp 0.3.0 → 1.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 +4 -4
- data/LICENSE +21 -0
- data/README.md +173 -356
- data/docs/ADVANCED_USAGE.md +967 -0
- data/docs/ARCHITECTURE.md +79 -0
- data/docs/BRANCH_ONLY_COVERAGE.md +81 -0
- data/docs/CLI_USAGE.md +637 -0
- data/docs/DEVELOPMENT.md +82 -0
- data/docs/ERROR_HANDLING.md +93 -0
- data/docs/EXAMPLES.md +430 -0
- data/docs/INSTALLATION.md +352 -0
- data/docs/LIBRARY_API.md +635 -0
- data/docs/MCP_INTEGRATION.md +488 -0
- data/docs/TROUBLESHOOTING.md +276 -0
- data/docs/arch-decisions/001-x-arch-decision.md +93 -0
- data/docs/arch-decisions/002-x-arch-decision.md +157 -0
- data/docs/arch-decisions/003-x-arch-decision.md +163 -0
- data/docs/arch-decisions/004-x-arch-decision.md +199 -0
- data/docs/arch-decisions/005-x-arch-decision.md +187 -0
- data/docs/arch-decisions/README.md +60 -0
- data/docs/presentations/simplecov-mcp-presentation.md +249 -0
- data/exe/simplecov-mcp +4 -4
- data/lib/simplecov_mcp/app_context.rb +26 -0
- data/lib/simplecov_mcp/base_tool.rb +74 -0
- data/lib/simplecov_mcp/cli.rb +234 -0
- data/lib/simplecov_mcp/cli_config.rb +56 -0
- data/lib/simplecov_mcp/commands/base_command.rb +78 -0
- data/lib/simplecov_mcp/commands/command_factory.rb +39 -0
- data/lib/simplecov_mcp/commands/detailed_command.rb +24 -0
- data/lib/simplecov_mcp/commands/list_command.rb +13 -0
- data/lib/simplecov_mcp/commands/raw_command.rb +22 -0
- data/lib/simplecov_mcp/commands/summary_command.rb +24 -0
- data/lib/simplecov_mcp/commands/uncovered_command.rb +26 -0
- data/lib/simplecov_mcp/commands/version_command.rb +18 -0
- data/lib/simplecov_mcp/constants.rb +22 -0
- data/lib/simplecov_mcp/error_handler.rb +124 -0
- data/lib/simplecov_mcp/error_handler_factory.rb +31 -0
- data/lib/simplecov_mcp/errors.rb +179 -0
- data/lib/simplecov_mcp/formatters/source_formatter.rb +148 -0
- data/lib/simplecov_mcp/mcp_server.rb +40 -0
- data/lib/simplecov_mcp/mode_detector.rb +55 -0
- data/lib/simplecov_mcp/model.rb +300 -0
- data/lib/simplecov_mcp/option_normalizers.rb +92 -0
- data/lib/simplecov_mcp/option_parser_builder.rb +134 -0
- data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +50 -0
- data/lib/simplecov_mcp/option_parsers/error_helper.rb +109 -0
- data/lib/simplecov_mcp/path_relativizer.rb +61 -0
- data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +44 -0
- data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +52 -0
- data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +126 -0
- data/lib/simplecov_mcp/resolvers/resolver_factory.rb +28 -0
- data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +78 -0
- data/lib/simplecov_mcp/resultset_loader.rb +136 -0
- data/lib/simplecov_mcp/staleness_checker.rb +243 -0
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/all_files_coverage_tool.rb +31 -13
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_detailed_tool.rb +7 -7
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_raw_tool.rb +7 -7
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_summary_tool.rb +7 -7
- data/lib/simplecov_mcp/tools/coverage_table_tool.rb +90 -0
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/help_tool.rb +13 -4
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/uncovered_lines_tool.rb +7 -7
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/version_tool.rb +11 -3
- data/lib/simplecov_mcp/util.rb +82 -0
- data/lib/{simple_cov_mcp → simplecov_mcp}/version.rb +1 -1
- data/lib/simplecov_mcp.rb +144 -2
- data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
- data/spec/TIMESTAMPS.md +48 -0
- data/spec/all_files_coverage_tool_spec.rb +29 -25
- data/spec/base_tool_spec.rb +11 -10
- data/spec/cli/show_default_report_spec.rb +33 -0
- data/spec/cli_config_spec.rb +137 -0
- data/spec/cli_enumerated_options_spec.rb +68 -0
- data/spec/cli_error_spec.rb +105 -47
- data/spec/cli_source_spec.rb +82 -23
- data/spec/cli_spec.rb +140 -5
- data/spec/cli_success_predicate_spec.rb +141 -0
- data/spec/cli_table_spec.rb +1 -1
- data/spec/cli_usage_spec.rb +10 -26
- data/spec/commands/base_command_spec.rb +187 -0
- data/spec/commands/command_factory_spec.rb +72 -0
- data/spec/commands/detailed_command_spec.rb +48 -0
- data/spec/commands/raw_command_spec.rb +46 -0
- data/spec/commands/summary_command_spec.rb +47 -0
- data/spec/commands/uncovered_command_spec.rb +49 -0
- data/spec/constants_spec.rb +61 -0
- data/spec/coverage_table_tool_spec.rb +17 -33
- data/spec/error_handler_spec.rb +22 -13
- data/spec/error_mode_spec.rb +143 -0
- data/spec/errors_edge_cases_spec.rb +239 -0
- data/spec/errors_stale_spec.rb +2 -2
- data/spec/file_based_mcp_tools_spec.rb +99 -0
- data/spec/fixtures/project1/lib/bar.rb +0 -1
- data/spec/fixtures/project1/lib/foo.rb +0 -1
- data/spec/help_tool_spec.rb +11 -17
- data/spec/integration_spec.rb +845 -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 +15 -4
- data/spec/mode_detector_spec.rb +148 -0
- data/spec/model_error_handling_spec.rb +210 -0
- data/spec/model_staleness_spec.rb +40 -10
- data/spec/option_normalizers_spec.rb +204 -0
- data/spec/option_parsers/env_options_parser_spec.rb +233 -0
- data/spec/option_parsers/error_helper_spec.rb +222 -0
- data/spec/path_relativizer_spec.rb +83 -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 +86 -0
- data/spec/resolvers/coverage_line_resolver_spec.rb +57 -0
- data/spec/resolvers/resolver_factory_spec.rb +61 -0
- data/spec/resolvers/resultset_path_resolver_spec.rb +55 -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 +174 -0
- data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
- data/spec/simple_cov_mcp_module_spec.rb +16 -0
- data/spec/simplecov_mcp_model_spec.rb +340 -9
- data/spec/simplecov_mcp_opts_spec.rb +182 -0
- data/spec/spec_helper.rb +147 -4
- data/spec/staleness_checker_spec.rb +373 -0
- data/spec/staleness_more_spec.rb +16 -13
- data/spec/support/mcp_runner.rb +64 -0
- data/spec/tools_error_handling_spec.rb +144 -0
- data/spec/util_spec.rb +109 -34
- data/spec/version_spec.rb +117 -9
- data/spec/version_tool_spec.rb +131 -10
- metadata +120 -63
- data/lib/simple_cov/mcp.rb +0 -9
- data/lib/simple_cov_mcp/base_tool.rb +0 -70
- data/lib/simple_cov_mcp/cli.rb +0 -390
- data/lib/simple_cov_mcp/error_handler.rb +0 -131
- data/lib/simple_cov_mcp/error_handler_factory.rb +0 -38
- data/lib/simple_cov_mcp/errors.rb +0 -176
- data/lib/simple_cov_mcp/mcp_server.rb +0 -30
- data/lib/simple_cov_mcp/model.rb +0 -104
- data/lib/simple_cov_mcp/staleness_checker.rb +0 -125
- data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +0 -61
- data/lib/simple_cov_mcp/util.rb +0 -122
- data/lib/simple_cov_mcp.rb +0 -102
- data/spec/coverage_detailed_tool_spec.rb +0 -36
- data/spec/coverage_raw_tool_spec.rb +0 -32
- data/spec/coverage_summary_tool_spec.rb +0 -39
- data/spec/legacy_shim_spec.rb +0 -13
- data/spec/uncovered_lines_tool_spec.rb +0 -33
@@ -0,0 +1,300 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'time'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
require_relative 'util'
|
7
|
+
require_relative 'errors'
|
8
|
+
require_relative 'staleness_checker'
|
9
|
+
require_relative 'path_relativizer'
|
10
|
+
require_relative 'resultset_loader'
|
11
|
+
|
12
|
+
module SimpleCovMcp
|
13
|
+
class CoverageModel
|
14
|
+
RELATIVIZER_SCALAR_KEYS = %w[file file_path].freeze
|
15
|
+
RELATIVIZER_ARRAY_KEYS = %w[newer_files missing_files deleted_files].freeze
|
16
|
+
|
17
|
+
attr_reader :relativizer
|
18
|
+
|
19
|
+
# Create a CoverageModel
|
20
|
+
#
|
21
|
+
# Params:
|
22
|
+
# - root: project root directory (default '.')
|
23
|
+
# - resultset: path or directory to .resultset.json
|
24
|
+
# - staleness: 'off' or 'error' (default 'off'). When 'error', raises
|
25
|
+
# stale errors if sources are newer than coverage or line counts mismatch.
|
26
|
+
# - tracked_globs: only used for all_files project-level staleness.
|
27
|
+
def initialize(root: '.', resultset: nil, staleness: 'off', tracked_globs: nil)
|
28
|
+
@root = File.absolute_path(root || '.')
|
29
|
+
@resultset = resultset
|
30
|
+
@relativizer = PathRelativizer.new(
|
31
|
+
root: @root,
|
32
|
+
scalar_keys: RELATIVIZER_SCALAR_KEYS,
|
33
|
+
array_keys: RELATIVIZER_ARRAY_KEYS
|
34
|
+
)
|
35
|
+
|
36
|
+
begin
|
37
|
+
rs = CovUtil.find_resultset(@root, resultset: resultset)
|
38
|
+
loaded = ResultsetLoader.load(resultset_path: rs)
|
39
|
+
coverage_map = loaded.coverage_map or raise CoverageDataError.new("No 'coverage' key found in resultset file: #{rs}")
|
40
|
+
|
41
|
+
@cov = coverage_map.transform_keys { |k| File.absolute_path(k, @root) }
|
42
|
+
@cov_timestamp = loaded.timestamp
|
43
|
+
|
44
|
+
@checker = StalenessChecker.new(
|
45
|
+
root: @root,
|
46
|
+
resultset: @resultset,
|
47
|
+
mode: staleness,
|
48
|
+
tracked_globs: tracked_globs,
|
49
|
+
timestamp: @cov_timestamp
|
50
|
+
)
|
51
|
+
rescue Errno::ENOENT => e
|
52
|
+
raise FileError.new("Coverage data not found at #{resultset || @root}")
|
53
|
+
rescue JSON::ParserError => e
|
54
|
+
raise CoverageDataError.new("Invalid coverage data format: #{e.message}")
|
55
|
+
rescue Errno::EACCES => e
|
56
|
+
raise FilePermissionError.new("Permission denied reading coverage data: #{e.message}")
|
57
|
+
rescue TypeError, NoMethodError => e
|
58
|
+
# These typically indicate the resultset has an unexpected structure
|
59
|
+
raise CoverageDataError.new("Invalid coverage data structure: #{e.message}")
|
60
|
+
rescue ArgumentError => e
|
61
|
+
# ArgumentError can occur from File.absolute_path or other path operations
|
62
|
+
raise CoverageDataError.new("Invalid path in coverage data: #{e.message}")
|
63
|
+
rescue RuntimeError => e
|
64
|
+
# RuntimeError from find_resultset or other operations
|
65
|
+
raise CoverageDataError.new("Failed to load coverage data: #{e.message}")
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Returns { 'file' => <absolute_path>, 'lines' => [hits|nil,...] }
|
70
|
+
def raw_for(path)
|
71
|
+
file_abs, coverage_lines = resolve(path)
|
72
|
+
{ 'file' => file_abs, 'lines' => coverage_lines }
|
73
|
+
end
|
74
|
+
|
75
|
+
def relativize(payload)
|
76
|
+
relativizer.relativize(payload)
|
77
|
+
end
|
78
|
+
|
79
|
+
# Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, 'pct'=>} }
|
80
|
+
def summary_for(path)
|
81
|
+
file_abs, coverage_lines = resolve(path)
|
82
|
+
{ 'file' => file_abs, 'summary' => CovUtil.summary(coverage_lines) }
|
83
|
+
end
|
84
|
+
|
85
|
+
# Returns { 'file' => <absolute_path>, 'uncovered' => [line,...], 'summary' => {...} }
|
86
|
+
def uncovered_for(path)
|
87
|
+
file_abs, coverage_lines = resolve(path)
|
88
|
+
{
|
89
|
+
'file' => file_abs,
|
90
|
+
'uncovered' => CovUtil.uncovered(coverage_lines),
|
91
|
+
'summary' => CovUtil.summary(coverage_lines)
|
92
|
+
}
|
93
|
+
end
|
94
|
+
|
95
|
+
# Returns { 'file' => <absolute_path>, 'lines' => [{'line'=>,'hits'=>,'covered'=>},...], 'summary' => {...} }
|
96
|
+
def detailed_for(path)
|
97
|
+
file_abs, coverage_lines = resolve(path)
|
98
|
+
{
|
99
|
+
'file' => file_abs,
|
100
|
+
'lines' => CovUtil.detailed(coverage_lines),
|
101
|
+
'summary' => CovUtil.summary(coverage_lines)
|
102
|
+
}
|
103
|
+
end
|
104
|
+
|
105
|
+
# Returns [ { 'file' =>, 'covered' =>, 'total' =>, 'percentage' =>, 'stale' => }, ... ]
|
106
|
+
def all_files(sort_order: :ascending, check_stale: !@checker.off?, tracked_globs: nil)
|
107
|
+
stale_checker = build_staleness_checker(mode: 'off', tracked_globs: tracked_globs)
|
108
|
+
|
109
|
+
rows = @cov.map do |abs_path, _data|
|
110
|
+
begin
|
111
|
+
coverage_lines = CovUtil.lookup_lines(@cov, abs_path)
|
112
|
+
rescue FileError
|
113
|
+
next
|
114
|
+
end
|
115
|
+
|
116
|
+
s = CovUtil.summary(coverage_lines)
|
117
|
+
stale = stale_checker.stale_for_file?(abs_path, coverage_lines)
|
118
|
+
{
|
119
|
+
'file' => abs_path,
|
120
|
+
'covered' => s['covered'],
|
121
|
+
'total' => s['total'],
|
122
|
+
'percentage' => s['pct'],
|
123
|
+
'stale' => stale
|
124
|
+
}
|
125
|
+
end.compact
|
126
|
+
|
127
|
+
rows = filter_rows_by_globs(rows, tracked_globs)
|
128
|
+
|
129
|
+
if check_stale
|
130
|
+
build_staleness_checker(mode: 'error', tracked_globs: tracked_globs).check_project!(@cov)
|
131
|
+
end
|
132
|
+
|
133
|
+
sort_rows(rows, sort_order: sort_order)
|
134
|
+
end
|
135
|
+
|
136
|
+
def staleness_for(path)
|
137
|
+
file_abs = File.absolute_path(path, @root)
|
138
|
+
coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
|
139
|
+
@checker.stale_for_file?(file_abs, coverage_lines)
|
140
|
+
rescue StandardError => e
|
141
|
+
# Log the error if possible
|
142
|
+
CovUtil.log("Failed to check staleness for #{path}: #{e.message}") rescue nil
|
143
|
+
false
|
144
|
+
end
|
145
|
+
|
146
|
+
# Returns formatted table string for all files coverage data
|
147
|
+
def format_table(rows = nil, sort_order: :ascending, check_stale: !@checker.off?,
|
148
|
+
tracked_globs: nil)
|
149
|
+
rows = prepare_rows(rows, sort_order: sort_order, check_stale: check_stale,
|
150
|
+
tracked_globs: tracked_globs)
|
151
|
+
return 'No coverage data found' if rows.empty?
|
152
|
+
|
153
|
+
widths = compute_table_widths(rows)
|
154
|
+
lines = []
|
155
|
+
lines << border_line(widths, '┌', '┬', '┐')
|
156
|
+
lines << header_row(widths)
|
157
|
+
lines << border_line(widths, '├', '┼', '┤')
|
158
|
+
rows.each { |file_data| lines << data_row(file_data, widths) }
|
159
|
+
lines << border_line(widths, '└', '┴', '┘')
|
160
|
+
lines << summary_counts(rows)
|
161
|
+
if rows.any? { |f| f['stale'] }
|
162
|
+
lines <<
|
163
|
+
'Staleness: M = Missing file, T = Timestamp (source newer), L = Line count mismatch'
|
164
|
+
end
|
165
|
+
lines.join("\n")
|
166
|
+
end
|
167
|
+
|
168
|
+
private
|
169
|
+
|
170
|
+
def build_staleness_checker(mode:, tracked_globs:)
|
171
|
+
StalenessChecker.new(
|
172
|
+
root: @root,
|
173
|
+
resultset: @resultset,
|
174
|
+
mode: mode,
|
175
|
+
tracked_globs: tracked_globs,
|
176
|
+
timestamp: @cov_timestamp
|
177
|
+
)
|
178
|
+
end
|
179
|
+
|
180
|
+
def prepare_rows(rows, sort_order:, check_stale:, tracked_globs:)
|
181
|
+
if rows.nil?
|
182
|
+
all_files(sort_order: sort_order, check_stale: check_stale, tracked_globs: tracked_globs)
|
183
|
+
else
|
184
|
+
rows = sort_rows(rows.dup, sort_order: sort_order)
|
185
|
+
filter_rows_by_globs(rows, tracked_globs)
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
def sort_rows(rows, sort_order: :ascending)
|
190
|
+
rows.sort do |a, b|
|
191
|
+
pct_cmp = (sort_order == :descending) \
|
192
|
+
? (b['percentage'] <=> a['percentage'])
|
193
|
+
: (a['percentage'] <=> b['percentage'])
|
194
|
+
pct_cmp == 0 ? (a['file'] <=> b['file']) : pct_cmp
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def compute_table_widths(rows)
|
199
|
+
max_file_length = rows.map { |f| f['file'].length }.max.to_i
|
200
|
+
file_width = [max_file_length, 'File'.length].max + 2
|
201
|
+
pct_width = 8
|
202
|
+
max_covered = rows.map { |f| f['covered'].to_s.length }.max
|
203
|
+
max_total = rows.map { |f| f['total'].to_s.length }.max
|
204
|
+
covered_width = [max_covered, 'Covered'.length].max + 2
|
205
|
+
total_width = [max_total, 'Total'.length].max + 2
|
206
|
+
stale_width = 'Stale'.length
|
207
|
+
{
|
208
|
+
file: file_width,
|
209
|
+
pct: pct_width,
|
210
|
+
covered: covered_width,
|
211
|
+
total: total_width,
|
212
|
+
stale: stale_width
|
213
|
+
}
|
214
|
+
end
|
215
|
+
|
216
|
+
def border_line(widths, left, middle, right)
|
217
|
+
h_line = ->(col_width) { '─' * (col_width + 2) }
|
218
|
+
left +
|
219
|
+
h_line.call(widths[:file]) +
|
220
|
+
middle + h_line.call(widths[:pct]) +
|
221
|
+
middle + h_line.call(widths[:covered]) +
|
222
|
+
middle + h_line.call(widths[:total]) +
|
223
|
+
middle + h_line.call(widths[:stale]) +
|
224
|
+
right
|
225
|
+
end
|
226
|
+
|
227
|
+
def header_row(widths)
|
228
|
+
sprintf(
|
229
|
+
"│ %-#{widths[:file]}s │ %#{widths[:pct]}s │ %#{widths[:covered]}s │ %#{widths[:total]}s │ %#{widths[:stale]}s │",
|
230
|
+
'File', ' %', 'Covered', 'Total', 'Stale'.center(widths[:stale])
|
231
|
+
)
|
232
|
+
end
|
233
|
+
|
234
|
+
def data_row(file_data, widths)
|
235
|
+
stale_text_str = file_data['stale'] ? file_data['stale'].to_s : ''
|
236
|
+
sprintf(
|
237
|
+
"│ %-#{widths[:file]}s │ %#{widths[:pct] - 1}.2f%% │ %#{widths[:covered]}d │ %#{widths[:total]}d │ %#{widths[:stale]}s │",
|
238
|
+
file_data['file'],
|
239
|
+
file_data['percentage'],
|
240
|
+
file_data['covered'],
|
241
|
+
file_data['total'],
|
242
|
+
stale_text_str.center(widths[:stale])
|
243
|
+
)
|
244
|
+
end
|
245
|
+
|
246
|
+
def summary_counts(rows)
|
247
|
+
total = rows.length
|
248
|
+
stale_count = rows.count { |f| f['stale'] }
|
249
|
+
ok_count = total - stale_count
|
250
|
+
"Files: total #{total}, ok #{ok_count}, stale #{stale_count}"
|
251
|
+
end
|
252
|
+
|
253
|
+
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
|
+
root_pathname = Pathname.new(@root)
|
258
|
+
flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
259
|
+
|
260
|
+
rows.select do |row|
|
261
|
+
abs_path = row['file']
|
262
|
+
rel_path = begin
|
263
|
+
Pathname.new(abs_path).relative_path_from(root_pathname).to_s
|
264
|
+
rescue ArgumentError
|
265
|
+
abs_path
|
266
|
+
end
|
267
|
+
|
268
|
+
patterns.any? do |pattern|
|
269
|
+
target = Pathname.new(pattern).absolute? ? abs_path : rel_path
|
270
|
+
File.fnmatch?(pattern, target, flags)
|
271
|
+
end
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
def resolve(path)
|
276
|
+
file_abs = File.absolute_path(path, @root)
|
277
|
+
begin
|
278
|
+
coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
|
279
|
+
rescue RuntimeError => e
|
280
|
+
raise FileError.new("No coverage data found for file: #{path}")
|
281
|
+
end
|
282
|
+
@checker.check_file!(file_abs, coverage_lines) unless @checker.off?
|
283
|
+
if coverage_lines.nil?
|
284
|
+
raise FileError.new("No coverage data found for file: #{path}")
|
285
|
+
end
|
286
|
+
|
287
|
+
[file_abs, coverage_lines]
|
288
|
+
rescue Errno::ENOENT => e
|
289
|
+
raise FileNotFoundError.new("File not found: #{path}")
|
290
|
+
end
|
291
|
+
|
292
|
+
# staleness handled by StalenessChecker
|
293
|
+
|
294
|
+
def check_all_files_staleness!(cov_timestamp, tracked_globs: nil)
|
295
|
+
# handled by StalenessChecker
|
296
|
+
end
|
297
|
+
|
298
|
+
# Detailed stale message construction moved to CoverageDataStaleError
|
299
|
+
end
|
300
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SimpleCovMcp
|
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
|
+
STALE_MODE_MAP = {
|
22
|
+
'o' => :off,
|
23
|
+
'off' => :off,
|
24
|
+
'e' => :error,
|
25
|
+
'error' => :error
|
26
|
+
}.freeze
|
27
|
+
|
28
|
+
ERROR_MODE_MAP = {
|
29
|
+
'off' => :off,
|
30
|
+
'on' => :on,
|
31
|
+
't' => :trace,
|
32
|
+
'trace' => :trace
|
33
|
+
}.freeze
|
34
|
+
|
35
|
+
module_function
|
36
|
+
|
37
|
+
# Normalize sort order value.
|
38
|
+
# @param value [String, Symbol] The value to normalize
|
39
|
+
# @param strict [Boolean] If true, raises on invalid value; if false, returns nil
|
40
|
+
# @return [Symbol, nil] The normalized symbol or nil if invalid and not strict
|
41
|
+
# @raise [OptionParser::InvalidArgument] If strict and value is invalid
|
42
|
+
def normalize_sort_order(value, strict: true)
|
43
|
+
normalized = SORT_ORDER_MAP[value.to_s.downcase]
|
44
|
+
return normalized if normalized
|
45
|
+
raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
|
46
|
+
|
47
|
+
nil
|
48
|
+
end
|
49
|
+
|
50
|
+
# Normalize source mode value.
|
51
|
+
# @param value [String, Symbol, nil] The value to normalize
|
52
|
+
# @param strict [Boolean] If true, raises on invalid value; if false, returns nil
|
53
|
+
# @return [Symbol, nil] The normalized symbol or nil if invalid and not strict
|
54
|
+
# @raise [OptionParser::InvalidArgument] If strict and value is invalid
|
55
|
+
def normalize_source_mode(value, strict: true)
|
56
|
+
return :full if value.nil? || value == ''
|
57
|
+
|
58
|
+
normalized = SOURCE_MODE_MAP[value.to_s.downcase]
|
59
|
+
return normalized if normalized
|
60
|
+
raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
|
61
|
+
|
62
|
+
nil
|
63
|
+
end
|
64
|
+
|
65
|
+
# Normalize stale mode value.
|
66
|
+
# @param value [String, Symbol] The value to normalize
|
67
|
+
# @param strict [Boolean] If true, raises on invalid value; if false, returns nil
|
68
|
+
# @return [Symbol, nil] The normalized symbol or nil if invalid and not strict
|
69
|
+
# @raise [OptionParser::InvalidArgument] If strict and value is invalid
|
70
|
+
def normalize_stale_mode(value, strict: true)
|
71
|
+
normalized = STALE_MODE_MAP[value.to_s.downcase]
|
72
|
+
return normalized if normalized
|
73
|
+
raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
|
74
|
+
|
75
|
+
nil
|
76
|
+
end
|
77
|
+
|
78
|
+
# Normalize error mode value.
|
79
|
+
# @param value [String, Symbol, nil] The value to normalize
|
80
|
+
# @param strict [Boolean] If true, raises on invalid value; if false, returns default
|
81
|
+
# @param default [Symbol] The default value to return if invalid and not strict
|
82
|
+
# @return [Symbol] The normalized symbol or default if invalid and not strict
|
83
|
+
# @raise [OptionParser::InvalidArgument] If strict and value is invalid
|
84
|
+
def normalize_error_mode(value, strict: true, default: :on)
|
85
|
+
normalized = ERROR_MODE_MAP[value&.downcase]
|
86
|
+
return normalized if normalized
|
87
|
+
raise OptionParser::InvalidArgument, "invalid argument: #{value}" if strict
|
88
|
+
|
89
|
+
default
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'option_normalizers'
|
4
|
+
|
5
|
+
module SimpleCovMcp
|
6
|
+
class OptionParserBuilder
|
7
|
+
HORIZONTAL_RULE = '-' * 79
|
8
|
+
SUBCOMMANDS = %w[list summary raw uncovered detailed version].freeze
|
9
|
+
|
10
|
+
attr_reader :config
|
11
|
+
|
12
|
+
def initialize(config)
|
13
|
+
@config = config
|
14
|
+
end
|
15
|
+
|
16
|
+
def build_option_parser
|
17
|
+
require 'optparse'
|
18
|
+
OptionParser.new do |o|
|
19
|
+
configure_banner(o)
|
20
|
+
define_subcommands_help(o)
|
21
|
+
define_options(o)
|
22
|
+
define_examples(o)
|
23
|
+
add_help_handler(o)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def configure_banner(o)
|
30
|
+
o.banner = <<~BANNER
|
31
|
+
#{HORIZONTAL_RULE}
|
32
|
+
Usage: simplecov-mcp [subcommand] [options] [args]
|
33
|
+
Repository: https://github.com/keithrbennett/simplecov-mcp
|
34
|
+
Version: #{SimpleCovMcp::VERSION}
|
35
|
+
#{HORIZONTAL_RULE}
|
36
|
+
|
37
|
+
BANNER
|
38
|
+
end
|
39
|
+
|
40
|
+
def define_subcommands_help(o)
|
41
|
+
o.separator <<~SUBCOMMANDS
|
42
|
+
Subcommands:
|
43
|
+
list Show files coverage (table or --json)
|
44
|
+
summary <path> Show covered/total/% for a file
|
45
|
+
raw <path> Show the SimpleCov 'lines' array
|
46
|
+
uncovered <path> Show uncovered lines and a summary
|
47
|
+
detailed <path> Show per-line rows with hits/covered
|
48
|
+
version Show version information
|
49
|
+
|
50
|
+
SUBCOMMANDS
|
51
|
+
end
|
52
|
+
|
53
|
+
def define_options(o)
|
54
|
+
o.separator 'Options:'
|
55
|
+
o.on('-r', '--resultset PATH', String,
|
56
|
+
'Path or directory that contains .resultset.json (default: coverage/.resultset.json)') \
|
57
|
+
do |v|
|
58
|
+
config.resultset = v
|
59
|
+
end
|
60
|
+
o.on('-R', '--root PATH', String, 'Project root (default: .)') { |v| config.root = v }
|
61
|
+
o.on('-j', '--json', 'Output JSON for machine consumption') { config.json = true }
|
62
|
+
o.on('-o', '--sort-order ORDER', String,
|
63
|
+
'Sort order for list: a[scending]|d[escending] (default ascending)') do |v|
|
64
|
+
config.sort_order = normalize_sort_order(v)
|
65
|
+
end
|
66
|
+
o.on('-s', '--source[=MODE]', String,
|
67
|
+
'Include source (MODE: f[ull]|u[ncovered]; default full)') do |v|
|
68
|
+
config.source_mode = normalize_source_mode(v)
|
69
|
+
end
|
70
|
+
o.on('-c', '--source-context N', Integer,
|
71
|
+
'For --source=uncovered, show N context lines (default: 2)') do |v|
|
72
|
+
config.source_context = v
|
73
|
+
end
|
74
|
+
o.on('--color', 'Enable ANSI colors for source output') { config.color = true }
|
75
|
+
o.on('--no-color', 'Disable ANSI colors') { config.color = false }
|
76
|
+
o.on('-S', '--stale MODE', String,
|
77
|
+
'Staleness mode: o[ff]|e[rror] (default off)') do |v|
|
78
|
+
config.stale_mode = normalize_stale_mode(v)
|
79
|
+
end
|
80
|
+
o.on('-g', '--tracked-globs x,y,z', Array,
|
81
|
+
'Globs for filtering files (list subcommand)') do |v|
|
82
|
+
config.tracked_globs = v
|
83
|
+
end
|
84
|
+
o.on('-l', '--log-file PATH', String,
|
85
|
+
'Log file path (default ./simplecov_mcp.log, use stdout/stderr for streams)') do |v|
|
86
|
+
config.log_file = v
|
87
|
+
end
|
88
|
+
o.on('--error-mode MODE', String,
|
89
|
+
'Error handling mode: off|on|t[trace] (default on)') do |v|
|
90
|
+
config.error_mode = normalize_error_mode(v)
|
91
|
+
end
|
92
|
+
o.on('--force-cli', 'Force CLI mode (useful in scripts where auto-detection fails)') do
|
93
|
+
# This flag is mainly for mode detection - no action needed here
|
94
|
+
end
|
95
|
+
o.on('--success-predicate FILE', String,
|
96
|
+
'Ruby file returning callable; exits 0 if truthy, 1 if falsy') do |v|
|
97
|
+
config.success_predicate = v
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def define_examples(o)
|
102
|
+
o.separator <<~EXAMPLES
|
103
|
+
|
104
|
+
Examples:
|
105
|
+
simplecov-mcp list --resultset coverage
|
106
|
+
simplecov-mcp summary lib/foo.rb --json --resultset coverage
|
107
|
+
simplecov-mcp uncovered lib/foo.rb --source=uncovered --source-context 2
|
108
|
+
EXAMPLES
|
109
|
+
end
|
110
|
+
|
111
|
+
def add_help_handler(o)
|
112
|
+
o.on('-h', '--help', 'Show help') do
|
113
|
+
puts o
|
114
|
+
exit 0
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def normalize_sort_order(v)
|
119
|
+
OptionNormalizers.normalize_sort_order(v, strict: true)
|
120
|
+
end
|
121
|
+
|
122
|
+
def normalize_source_mode(v)
|
123
|
+
OptionNormalizers.normalize_source_mode(v, strict: true)
|
124
|
+
end
|
125
|
+
|
126
|
+
def normalize_stale_mode(v)
|
127
|
+
OptionNormalizers.normalize_stale_mode(v, strict: true)
|
128
|
+
end
|
129
|
+
|
130
|
+
def normalize_error_mode(v)
|
131
|
+
OptionNormalizers.normalize_error_mode(v, strict: true)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'shellwords'
|
4
|
+
require_relative '../option_normalizers'
|
5
|
+
|
6
|
+
module SimpleCovMcp
|
7
|
+
module OptionParsers
|
8
|
+
class EnvOptionsParser
|
9
|
+
ENV_VAR = 'SIMPLECOV_MCP_OPTS'
|
10
|
+
|
11
|
+
def initialize(env_var: ENV_VAR)
|
12
|
+
@env_var = env_var
|
13
|
+
end
|
14
|
+
|
15
|
+
def parse_env_opts
|
16
|
+
opts_string = ENV[@env_var]
|
17
|
+
return [] unless opts_string && !opts_string.empty?
|
18
|
+
|
19
|
+
begin
|
20
|
+
Shellwords.split(opts_string)
|
21
|
+
rescue ArgumentError => e
|
22
|
+
raise SimpleCovMcp::ConfigurationError, "Invalid #{@env_var} format: #{e.message}"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def pre_scan_error_mode(argv, error_mode_normalizer: method(:normalize_error_mode))
|
27
|
+
# Quick scan for --error-mode to ensure early errors are logged correctly
|
28
|
+
argv.each_with_index do |arg, i|
|
29
|
+
if arg == '--error-mode' && argv[i + 1]
|
30
|
+
return error_mode_normalizer.call(argv[i + 1])
|
31
|
+
elsif arg.start_with?('--error-mode=')
|
32
|
+
value = arg.split('=', 2)[1]
|
33
|
+
return nil if value.to_s.empty?
|
34
|
+
return error_mode_normalizer.call(value) if value
|
35
|
+
end
|
36
|
+
end
|
37
|
+
nil
|
38
|
+
rescue StandardError
|
39
|
+
# Ignore errors during pre-scan; they'll be caught during actual parsing
|
40
|
+
nil
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def normalize_error_mode(value)
|
46
|
+
OptionNormalizers.normalize_error_mode(value, strict: false, default: :on)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SimpleCovMcp
|
4
|
+
module OptionParsers
|
5
|
+
class ErrorHelper
|
6
|
+
SUBCOMMANDS = %w[list summary raw uncovered detailed version].freeze
|
7
|
+
|
8
|
+
def initialize(subcommands = SUBCOMMANDS)
|
9
|
+
@subcommands = subcommands
|
10
|
+
end
|
11
|
+
|
12
|
+
def handle_option_parser_error(error, argv: [], usage_hint: "Run '#{program_name} --help' for usage information.")
|
13
|
+
message = error.message.to_s
|
14
|
+
# Suggest a subcommand when an invalid option matches a known subcommand
|
15
|
+
option = extract_invalid_option(message)
|
16
|
+
|
17
|
+
if option && option.start_with?('--') && @subcommands.include?(option[2..-1])
|
18
|
+
suggest_subcommand(option)
|
19
|
+
else
|
20
|
+
# Generic message from OptionParser
|
21
|
+
warn "Error: #{message}"
|
22
|
+
# Attempt to derive a helpful hint for enumerated options
|
23
|
+
if (hint = build_enum_value_hint(argv))
|
24
|
+
warn hint
|
25
|
+
end
|
26
|
+
end
|
27
|
+
warn usage_hint
|
28
|
+
exit 1
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def extract_invalid_option(message)
|
34
|
+
message.match(/invalid option: (.+)/)[1] rescue nil
|
35
|
+
end
|
36
|
+
|
37
|
+
def suggest_subcommand(option)
|
38
|
+
subcommand = option[2..-1]
|
39
|
+
warn "Error: '#{option}' is not a valid option. Did you mean the '#{subcommand}' subcommand?"
|
40
|
+
warn "Try: #{program_name} #{subcommand} [args]"
|
41
|
+
end
|
42
|
+
|
43
|
+
def build_enum_value_hint(argv)
|
44
|
+
rules = enumerated_option_rules
|
45
|
+
tokens = Array(argv)
|
46
|
+
rules.each do |rule|
|
47
|
+
hint = build_hint_for_rule(rule, tokens)
|
48
|
+
return hint if hint
|
49
|
+
end
|
50
|
+
nil
|
51
|
+
end
|
52
|
+
|
53
|
+
def build_hint_for_rule(rule, tokens)
|
54
|
+
switches = rule[:switches]
|
55
|
+
allowed = rule[:values]
|
56
|
+
display = rule[:display] || allowed.join(', ')
|
57
|
+
preferred = switches.find { |s| s.start_with?('--') } || switches.first
|
58
|
+
|
59
|
+
tokens.each_with_index do |tok, i|
|
60
|
+
# --opt=value form
|
61
|
+
if equal_form_match?(tok, switches, preferred)
|
62
|
+
hint = handle_equal_form(tok, switches, preferred, display, allowed)
|
63
|
+
return hint if hint
|
64
|
+
end
|
65
|
+
|
66
|
+
# --opt value or -o value form
|
67
|
+
if switches.include?(tok)
|
68
|
+
hint = handle_space_form(tokens, i, preferred, display, allowed)
|
69
|
+
return hint if hint
|
70
|
+
end
|
71
|
+
end
|
72
|
+
nil
|
73
|
+
end
|
74
|
+
|
75
|
+
def equal_form_match?(token, switches, preferred)
|
76
|
+
token.start_with?(preferred + '=') || switches.any? { |s| token.start_with?(s + '=') }
|
77
|
+
end
|
78
|
+
|
79
|
+
def handle_equal_form(token, switches, preferred, display, allowed)
|
80
|
+
sw = switches.find { |s| token.start_with?(s + '=') } || preferred
|
81
|
+
val = token.split('=', 2)[1]
|
82
|
+
"Valid values for #{sw}: #{display}" if val && !allowed.include?(val)
|
83
|
+
end
|
84
|
+
|
85
|
+
def handle_space_form(tokens, index, preferred, display, allowed)
|
86
|
+
val = tokens[index + 1]
|
87
|
+
# If missing value, provide hint; if present and invalid, also hint
|
88
|
+
if val.nil? || val.start_with?('-') || !allowed.include?(val)
|
89
|
+
"Valid values for #{preferred}: #{display}"
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def enumerated_option_rules
|
94
|
+
[
|
95
|
+
{ switches: ['-S', '--stale'], values: %w[off o error e], display: 'o[ff]|e[rror]' },
|
96
|
+
{ switches: ['-s', '--source'], values: %w[full f uncovered u],
|
97
|
+
display: 'f[ull]|u[ncovered]' },
|
98
|
+
{ switches: ['--error-mode'], values: %w[off on trace t], display: 'off|on|t[race]' },
|
99
|
+
{ switches: ['-o', '--sort-order'], values: %w[a d ascending descending],
|
100
|
+
display: 'a[scending]|d[escending]' }
|
101
|
+
]
|
102
|
+
end
|
103
|
+
|
104
|
+
def program_name
|
105
|
+
'simplecov-mcp'
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|