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,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
require_relative '../base_tool'
|
|
5
|
+
require_relative '../presenters/project_coverage_presenter'
|
|
6
|
+
|
|
7
|
+
module CovLoupe
|
|
8
|
+
module Tools
|
|
9
|
+
class CoverageTableTool < BaseTool
|
|
10
|
+
description <<~DESC
|
|
11
|
+
Use this when a user wants the plain text coverage table exactly like `cov-loupe --table` would print (no ANSI colors).
|
|
12
|
+
Do not use this for machine-readable data; coverage.all_files returns structured JSON.
|
|
13
|
+
Inputs: optional project root/resultset path/sort order/staleness mode matching the CLI flags.
|
|
14
|
+
Output: text block containing the formatted coverage table with headers and percentages.
|
|
15
|
+
Example: "Show me the CLI coverage table sorted descending".
|
|
16
|
+
DESC
|
|
17
|
+
input_schema(**coverage_schema(
|
|
18
|
+
additional_properties: {
|
|
19
|
+
sort_order: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description: 'Sort order for the printed coverage table (ascending or descending).',
|
|
22
|
+
default: 'ascending',
|
|
23
|
+
enum: ['ascending', 'descending']
|
|
24
|
+
},
|
|
25
|
+
tracked_globs: TRACKED_GLOBS_PROPERTY
|
|
26
|
+
}
|
|
27
|
+
))
|
|
28
|
+
class << self
|
|
29
|
+
def call(root: '.', resultset: nil, sort_order: 'ascending', staleness: :off,
|
|
30
|
+
tracked_globs: nil, error_mode: 'log', server_context:)
|
|
31
|
+
with_error_handling('CoverageTableTool', error_mode: error_mode) do
|
|
32
|
+
# Convert string inputs from MCP to symbols for internal use
|
|
33
|
+
sort_order_sym = sort_order.to_sym
|
|
34
|
+
staleness_sym = staleness.to_sym
|
|
35
|
+
|
|
36
|
+
model = CoverageModel.new(root: root, resultset: resultset, staleness: staleness_sym,
|
|
37
|
+
tracked_globs: tracked_globs)
|
|
38
|
+
table = model.format_table(
|
|
39
|
+
sort_order: sort_order_sym,
|
|
40
|
+
check_stale: (staleness_sym == :error),
|
|
41
|
+
tracked_globs: tracked_globs
|
|
42
|
+
)
|
|
43
|
+
# Return text response
|
|
44
|
+
::MCP::Tool::Response.new([{ 'type' => 'text', 'text' => table }])
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../model'
|
|
4
|
+
require_relative '../base_tool'
|
|
5
|
+
require_relative '../presenters/project_totals_presenter'
|
|
6
|
+
|
|
7
|
+
module CovLoupe
|
|
8
|
+
module Tools
|
|
9
|
+
class CoverageTotalsTool < BaseTool
|
|
10
|
+
description <<~DESC
|
|
11
|
+
Use this when you want aggregated coverage counts for the entire project.
|
|
12
|
+
It reports covered/total lines, uncovered line counts, and the overall average percentage.
|
|
13
|
+
Inputs: optional project root, alternate .resultset path, staleness mode, tracked_globs, and error mode.
|
|
14
|
+
Output: JSON {"lines":{"total","covered","uncovered"},"percentage":Float,"files":{"total","ok","stale"}}.
|
|
15
|
+
Example: "Give me total/covered/uncovered line counts and the overall coverage percent."
|
|
16
|
+
DESC
|
|
17
|
+
|
|
18
|
+
input_schema(**coverage_schema(
|
|
19
|
+
additional_properties: {
|
|
20
|
+
tracked_globs: TRACKED_GLOBS_PROPERTY
|
|
21
|
+
}
|
|
22
|
+
))
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
def call(root: '.', resultset: nil, staleness: :off, tracked_globs: nil,
|
|
26
|
+
error_mode: 'log', server_context:)
|
|
27
|
+
with_error_handling('CoverageTotalsTool', error_mode: error_mode) do
|
|
28
|
+
# Convert string inputs from MCP to symbols for internal use
|
|
29
|
+
staleness_sym = staleness.to_sym
|
|
30
|
+
|
|
31
|
+
model = CoverageModel.new(root: root, resultset: resultset, staleness: staleness_sym,
|
|
32
|
+
tracked_globs: tracked_globs)
|
|
33
|
+
presenter = Presenters::ProjectTotalsPresenter.new(
|
|
34
|
+
model: model,
|
|
35
|
+
check_stale: (staleness_sym == :error),
|
|
36
|
+
tracked_globs: tracked_globs
|
|
37
|
+
)
|
|
38
|
+
respond_json(presenter.relativized_payload, name: 'coverage_totals.json', pretty: true)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../base_tool'
|
|
4
|
+
|
|
5
|
+
module CovLoupe
|
|
6
|
+
module Tools
|
|
7
|
+
class HelpTool < BaseTool
|
|
8
|
+
description <<~DESC
|
|
9
|
+
Returns help containing descriptions of all tools, including: use_when, avoid_when, inputs.
|
|
10
|
+
DESC
|
|
11
|
+
|
|
12
|
+
input_schema(
|
|
13
|
+
type: 'object',
|
|
14
|
+
additionalProperties: false,
|
|
15
|
+
properties: {
|
|
16
|
+
error_mode: ERROR_MODE_PROPERTY
|
|
17
|
+
}
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
TOOL_GUIDE = [
|
|
21
|
+
{
|
|
22
|
+
tool: CoverageSummaryTool,
|
|
23
|
+
label: 'Single-file coverage summary',
|
|
24
|
+
use_when: 'User wants covered/total line counts or percentage for one file.',
|
|
25
|
+
avoid_when: 'User needs repo-wide stats or specific uncovered lines.',
|
|
26
|
+
inputs: ['path (required)', 'root/resultset/staleness (optional)']
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
tool: UncoveredLinesTool,
|
|
30
|
+
label: 'Uncovered line numbers',
|
|
31
|
+
use_when: 'User asks which lines in a file still lack tests.',
|
|
32
|
+
avoid_when: 'User only wants overall percentages or detailed per-line hit data.',
|
|
33
|
+
inputs: ['path (required)', 'root/resultset/staleness (optional)']
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
tool: CoverageDetailedTool,
|
|
37
|
+
label: 'Per-line coverage details',
|
|
38
|
+
use_when: 'User needs per-line hit counts for a file.',
|
|
39
|
+
avoid_when: 'User only wants totals or uncovered line numbers.',
|
|
40
|
+
inputs: ['path (required)', 'root/resultset/staleness (optional)']
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
tool: CoverageRawTool,
|
|
44
|
+
label: 'Raw SimpleCov lines array',
|
|
45
|
+
use_when: 'User needs the raw SimpleCov `lines` array for a file.',
|
|
46
|
+
avoid_when: 'User expects human-friendly summaries or explanations.',
|
|
47
|
+
inputs: ['path (required)', 'root/resultset/staleness (optional)']
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
tool: AllFilesCoverageTool,
|
|
51
|
+
label: 'Repo-wide file coverage',
|
|
52
|
+
use_when: 'User wants coverage percentages for every tracked file.',
|
|
53
|
+
avoid_when: 'User asks about a single file.',
|
|
54
|
+
inputs: ['root/resultset (optional)', 'sort_order', 'staleness', 'tracked_globs']
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
tool: CoverageTotalsTool,
|
|
58
|
+
label: 'Project coverage totals',
|
|
59
|
+
use_when: 'User wants total/covered/uncovered line counts or the average percent.',
|
|
60
|
+
avoid_when: 'User needs per-file breakdowns.',
|
|
61
|
+
inputs: ['root/resultset (optional)', 'staleness', 'tracked_globs']
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
tool: CoverageTableTool,
|
|
65
|
+
label: 'Formatted coverage table',
|
|
66
|
+
use_when: 'User wants the plain-text table produced by the CLI.',
|
|
67
|
+
avoid_when: 'User needs JSON data for automation.',
|
|
68
|
+
inputs: ['root/resultset (optional)', 'sort_order', 'staleness']
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
tool: ValidateTool,
|
|
72
|
+
label: 'Validate coverage policy',
|
|
73
|
+
use_when: 'User needs to enforce coverage rules (e.g., minimum percentage) in CI.',
|
|
74
|
+
avoid_when: 'User just wants to view coverage data.',
|
|
75
|
+
inputs: ['path (required)', 'root/resultset (optional)']
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
tool: ValidateTool,
|
|
79
|
+
label: 'Validate coverage policy',
|
|
80
|
+
use_when: 'User needs to enforce coverage rules (e.g., minimum percentage) in CI.',
|
|
81
|
+
avoid_when: 'User just wants to view coverage data.',
|
|
82
|
+
inputs: ['path (required)', 'root/resultset (optional)']
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
tool: VersionTool,
|
|
86
|
+
label: 'cov-loupe version',
|
|
87
|
+
use_when: 'User needs to confirm the running gem version.',
|
|
88
|
+
avoid_when: 'User is asking for coverage information.',
|
|
89
|
+
inputs: ['(no arguments)']
|
|
90
|
+
}
|
|
91
|
+
].freeze
|
|
92
|
+
|
|
93
|
+
class << self
|
|
94
|
+
def call(error_mode: 'log', server_context:, **_unused)
|
|
95
|
+
with_error_handling('HelpTool', error_mode: error_mode) do
|
|
96
|
+
entries = TOOL_GUIDE.map { |guide| format_entry(guide) }
|
|
97
|
+
|
|
98
|
+
data = { tools: entries }
|
|
99
|
+
respond_json(data, name: 'tools_help.json')
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private def format_entry(guide)
|
|
104
|
+
{
|
|
105
|
+
'tool' => guide[:tool].tool_name,
|
|
106
|
+
'label' => guide[:label],
|
|
107
|
+
'use_when' => guide[:use_when],
|
|
108
|
+
'avoid_when' => guide[:avoid_when],
|
|
109
|
+
'inputs' => guide[:inputs]
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../base_tool'
|
|
4
|
+
require_relative '../model'
|
|
5
|
+
require_relative '../presenters/coverage_uncovered_presenter'
|
|
6
|
+
|
|
7
|
+
module CovLoupe
|
|
8
|
+
module Tools
|
|
9
|
+
class UncoveredLinesTool < BaseTool
|
|
10
|
+
description <<~DESC
|
|
11
|
+
Use this when the user wants to know which lines in a file still lack coverage.
|
|
12
|
+
Do not use this for overall percentages; coverage.summary is faster when counts are enough.
|
|
13
|
+
Inputs: file path (required) plus optional root/resultset/staleness mode inherited from BaseTool.
|
|
14
|
+
Output: JSON object with keys "file", "uncovered" (array of integers), "summary" {"covered","total","percentage"}, and "stale" status.
|
|
15
|
+
Example: "List uncovered lines for lib/cov_loupe/tools/coverage_summary_tool.rb".
|
|
16
|
+
DESC
|
|
17
|
+
input_schema(**input_schema_def)
|
|
18
|
+
class << self
|
|
19
|
+
def call(path:, root: '.', resultset: nil, staleness: :off, error_mode: 'log',
|
|
20
|
+
server_context:)
|
|
21
|
+
with_error_handling('UncoveredLinesTool', error_mode: error_mode) do
|
|
22
|
+
model = CoverageModel.new(
|
|
23
|
+
root: root,
|
|
24
|
+
resultset: resultset,
|
|
25
|
+
staleness: staleness.to_sym
|
|
26
|
+
)
|
|
27
|
+
presenter = Presenters::CoverageUncoveredPresenter.new(model: model, path: path)
|
|
28
|
+
respond_json(presenter.relativized_payload, name: 'uncovered_lines.json', pretty: true)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../base_tool'
|
|
4
|
+
require_relative '../model'
|
|
5
|
+
require_relative '../predicate_evaluator'
|
|
6
|
+
|
|
7
|
+
module CovLoupe
|
|
8
|
+
module Tools
|
|
9
|
+
class ValidateTool < BaseTool
|
|
10
|
+
description <<~DESC
|
|
11
|
+
Validates coverage data against a predicate (Ruby code that evaluates to true/false).
|
|
12
|
+
Use this to enforce coverage policies programmatically.
|
|
13
|
+
Inputs: Either 'code' (Ruby string) OR 'file' (path to Ruby file), plus optional root/resultset/staleness/error_mode.
|
|
14
|
+
Output: JSON object {"result": Boolean} where true means policy passed, false means failed.
|
|
15
|
+
On error (syntax error, file not found, etc.), returns an MCP error response.
|
|
16
|
+
Security Warning: Predicates execute as arbitrary Ruby code with full system privileges.
|
|
17
|
+
Examples:
|
|
18
|
+
- "Check if all files have at least 80% coverage" → {"code": "->(m) { m.all_files.all? { |f| f['percentage'] >= 80 } }"}
|
|
19
|
+
- "Run coverage policy from file" → {"file": "coverage_policy.rb"}
|
|
20
|
+
DESC
|
|
21
|
+
|
|
22
|
+
input_schema(**coverage_schema(
|
|
23
|
+
additional_properties: {
|
|
24
|
+
code: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Ruby code string that returns a callable predicate. ' \
|
|
27
|
+
'Must evaluate to a lambda, proc, or object with #call method.'
|
|
28
|
+
},
|
|
29
|
+
file: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description:
|
|
32
|
+
'Path to Ruby file containing predicate code (absolute or relative to root).'
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
))
|
|
36
|
+
class << self
|
|
37
|
+
def call(code: nil, file: nil, root: '.', resultset: nil, staleness: :off,
|
|
38
|
+
error_mode: 'log', server_context:)
|
|
39
|
+
with_error_handling('ValidateTool', error_mode: error_mode) do
|
|
40
|
+
# Re-use logic from ValidateCommand, but adapt for MCP return format
|
|
41
|
+
require_relative '../cli'
|
|
42
|
+
|
|
43
|
+
# Create a minimal CLI shim to reuse command logic
|
|
44
|
+
cli = CoverageCLI.new
|
|
45
|
+
cli.config.root = root
|
|
46
|
+
cli.config.resultset = resultset
|
|
47
|
+
cli.config.staleness = staleness.to_sym
|
|
48
|
+
cli.config.error_mode = error_mode.to_sym
|
|
49
|
+
|
|
50
|
+
# We need to capture the boolean result instead of letting it exit
|
|
51
|
+
# Commands::ValidateCommand is designed to exit, so we'll use the model and evaluator directly
|
|
52
|
+
# This duplicates some logic from ValidateCommand#execute but avoids the exit(status) call
|
|
53
|
+
|
|
54
|
+
model = CoverageModel.new(**cli.config.model_options)
|
|
55
|
+
|
|
56
|
+
result = if code
|
|
57
|
+
PredicateEvaluator.evaluate_code(code, model)
|
|
58
|
+
elsif file
|
|
59
|
+
# Resolve file path relative to root if needed
|
|
60
|
+
predicate_path = File.expand_path(file, root)
|
|
61
|
+
PredicateEvaluator.evaluate_file(predicate_path, model)
|
|
62
|
+
else
|
|
63
|
+
raise UsageError, "Either 'code' or 'file' must be provided"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
respond_json({ result: result }, name: 'validate_result.json', pretty: true)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../base_tool'
|
|
4
|
+
|
|
5
|
+
module CovLoupe
|
|
6
|
+
module Tools
|
|
7
|
+
class VersionTool < BaseTool
|
|
8
|
+
description <<~DESC
|
|
9
|
+
Use this when the user or client needs to confirm which version of cov-loupe is running.
|
|
10
|
+
This tool takes no arguments and only returns the version string; avoid it for coverage data.
|
|
11
|
+
Output: plain text line "CovLoupe version: x.y.z".
|
|
12
|
+
Example: "What version of cov-loupe is installed?".
|
|
13
|
+
DESC
|
|
14
|
+
input_schema(
|
|
15
|
+
type: 'object',
|
|
16
|
+
additionalProperties: false,
|
|
17
|
+
properties: {
|
|
18
|
+
error_mode: ERROR_MODE_PROPERTY
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
class << self
|
|
22
|
+
def call(error_mode: 'log', server_context: nil, **_args)
|
|
23
|
+
with_error_handling('VersionTool', error_mode: error_mode) do
|
|
24
|
+
::MCP::Tool::Response.new([
|
|
25
|
+
{ 'type' => 'text', 'text' => "CovLoupe version: #{CovLoupe::VERSION}" }
|
|
26
|
+
])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'resolvers/resolver_factory'
|
|
4
|
+
|
|
5
|
+
module CovLoupe
|
|
6
|
+
RESULTSET_CANDIDATES = [
|
|
7
|
+
'.resultset.json',
|
|
8
|
+
'coverage/.resultset.json',
|
|
9
|
+
'tmp/.resultset.json'
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
DEFAULT_LOG_FILESPEC = './cov_loupe.log'
|
|
13
|
+
|
|
14
|
+
module CovUtil
|
|
15
|
+
module_function def log(msg)
|
|
16
|
+
log_file = CovLoupe.active_log_file
|
|
17
|
+
|
|
18
|
+
case log_file
|
|
19
|
+
when 'stdout'
|
|
20
|
+
$stdout.puts "[#{Time.now.iso8601}] #{msg}"
|
|
21
|
+
when 'stderr'
|
|
22
|
+
$stderr.puts "[#{Time.now.iso8601}] #{msg}"
|
|
23
|
+
else
|
|
24
|
+
# Handles both nil (default) and custom file paths
|
|
25
|
+
path_to_log = log_file || DEFAULT_LOG_FILESPEC
|
|
26
|
+
File.open(File.expand_path(path_to_log), 'a') { |f| f.puts "[#{Time.now.iso8601}] #{msg}" }
|
|
27
|
+
end
|
|
28
|
+
rescue => e
|
|
29
|
+
# Fallback to stderr if file logging fails, but suppress in MCP mode
|
|
30
|
+
# to avoid interfering with JSON-RPC protocol
|
|
31
|
+
unless CovLoupe.context.mcp_mode?
|
|
32
|
+
begin
|
|
33
|
+
$stderr.puts "[#{Time.now.iso8601}] LOGGING ERROR: #{e.message}"
|
|
34
|
+
$stderr.puts "[#{Time.now.iso8601}] #{msg}"
|
|
35
|
+
rescue
|
|
36
|
+
# Silently ignore only stderr fallback failures
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Safe logging that never raises - use when logging should not interrupt execution.
|
|
42
|
+
# Unlike `log`, this method guarantees it will never propagate exceptions.
|
|
43
|
+
module_function def safe_log(msg)
|
|
44
|
+
log(msg)
|
|
45
|
+
rescue
|
|
46
|
+
# Silently ignore all logging failures
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
module_function def find_resultset(root, resultset: nil)
|
|
50
|
+
Resolvers::ResolverFactory.find_resultset(root, resultset: resultset)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
module_function def lookup_lines(cov, file_abs)
|
|
54
|
+
Resolvers::ResolverFactory.lookup_lines(cov, file_abs)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
module_function def summary(arr)
|
|
58
|
+
total = 0
|
|
59
|
+
covered = 0
|
|
60
|
+
arr.compact.each do |hits|
|
|
61
|
+
total += 1
|
|
62
|
+
covered += 1 if hits.to_i > 0
|
|
63
|
+
end
|
|
64
|
+
percentage = total.zero? ? 100.0 : ((covered.to_f * 100.0 / total) * 100).round / 100.0
|
|
65
|
+
{ 'covered' => covered, 'total' => total, 'percentage' => percentage }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
module_function def uncovered(arr)
|
|
69
|
+
out = []
|
|
70
|
+
|
|
71
|
+
arr.each_with_index do |hits, i|
|
|
72
|
+
next if hits.nil?
|
|
73
|
+
|
|
74
|
+
out << (i + 1) if hits.to_i.zero?
|
|
75
|
+
end
|
|
76
|
+
out
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
module_function def detailed(arr)
|
|
80
|
+
rows = []
|
|
81
|
+
arr.each_with_index do |hits, i|
|
|
82
|
+
h = hits&.to_i
|
|
83
|
+
rows << { 'line' => i + 1, 'hits' => h, 'covered' => h.positive? } if h
|
|
84
|
+
end
|
|
85
|
+
rows
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/cov_loupe.rb
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
require 'set' # rubocop:disable Lint/RedundantRequireStatement -- Ruby >= 3.4 requires explicit require for set; RuboCop targets 3.2
|
|
7
|
+
require 'optparse'
|
|
8
|
+
require 'mcp'
|
|
9
|
+
require 'mcp/server/transports/stdio_transport'
|
|
10
|
+
|
|
11
|
+
require_relative 'cov_loupe/version'
|
|
12
|
+
require_relative 'cov_loupe/app_context'
|
|
13
|
+
require_relative 'cov_loupe/util'
|
|
14
|
+
require_relative 'cov_loupe/errors'
|
|
15
|
+
require_relative 'cov_loupe/error_handler'
|
|
16
|
+
require_relative 'cov_loupe/error_handler_factory'
|
|
17
|
+
require_relative 'cov_loupe/path_relativizer'
|
|
18
|
+
require_relative 'cov_loupe/resultset_loader'
|
|
19
|
+
require_relative 'cov_loupe/mode_detector'
|
|
20
|
+
require_relative 'cov_loupe/model'
|
|
21
|
+
require_relative 'cov_loupe/coverage_reporter'
|
|
22
|
+
require_relative 'cov_loupe/base_tool'
|
|
23
|
+
require_relative 'cov_loupe/tools/coverage_raw_tool'
|
|
24
|
+
require_relative 'cov_loupe/tools/coverage_summary_tool'
|
|
25
|
+
require_relative 'cov_loupe/tools/uncovered_lines_tool'
|
|
26
|
+
require_relative 'cov_loupe/tools/coverage_detailed_tool'
|
|
27
|
+
require_relative 'cov_loupe/tools/all_files_coverage_tool'
|
|
28
|
+
require_relative 'cov_loupe/tools/coverage_totals_tool'
|
|
29
|
+
require_relative 'cov_loupe/tools/coverage_table_tool'
|
|
30
|
+
require_relative 'cov_loupe/tools/validate_tool'
|
|
31
|
+
require_relative 'cov_loupe/tools/version_tool'
|
|
32
|
+
require_relative 'cov_loupe/tools/help_tool'
|
|
33
|
+
require_relative 'cov_loupe/mcp_server'
|
|
34
|
+
require_relative 'cov_loupe/cli'
|
|
35
|
+
|
|
36
|
+
module CovLoupe
|
|
37
|
+
class << self
|
|
38
|
+
THREAD_CONTEXT_KEY = :cov_loupe_context
|
|
39
|
+
|
|
40
|
+
def run(argv)
|
|
41
|
+
# Prepend environment options once at entry point
|
|
42
|
+
full_argv = extract_env_opts + argv
|
|
43
|
+
|
|
44
|
+
if ModeDetector.cli_mode?(full_argv)
|
|
45
|
+
# CLI mode: pass merged argv to CoverageCLI
|
|
46
|
+
CoverageCLI.new.run(full_argv)
|
|
47
|
+
else
|
|
48
|
+
# MCP server mode: parse config once from full_argv
|
|
49
|
+
require_relative 'cov_loupe/config_parser'
|
|
50
|
+
config = ConfigParser.parse(full_argv)
|
|
51
|
+
|
|
52
|
+
if config.log_file == 'stdout'
|
|
53
|
+
raise ConfigurationError,
|
|
54
|
+
'Logging to stdout is not permitted in MCP server mode as it interferes with ' \
|
|
55
|
+
"the JSON-RPC protocol. Please use 'stderr' or a file path."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
handler = ErrorHandlerFactory.for_mcp_server(error_mode: config.error_mode)
|
|
59
|
+
context = create_context(error_handler: handler, log_target: config.log_file,
|
|
60
|
+
mode: :mcp)
|
|
61
|
+
with_context(context) { MCPServer.new(context: context).run }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def with_context(context)
|
|
66
|
+
previous = Thread.current[THREAD_CONTEXT_KEY]
|
|
67
|
+
Thread.current[THREAD_CONTEXT_KEY] = context
|
|
68
|
+
yield
|
|
69
|
+
ensure
|
|
70
|
+
Thread.current[THREAD_CONTEXT_KEY] = previous
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def context
|
|
74
|
+
Thread.current[THREAD_CONTEXT_KEY] || default_context
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def create_context(error_handler:, log_target: nil, mode: :library)
|
|
78
|
+
AppContext.new(
|
|
79
|
+
error_handler: error_handler,
|
|
80
|
+
log_target: log_target.nil? ? default_context.log_target : log_target,
|
|
81
|
+
mode: mode
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def default_log_file
|
|
86
|
+
default_context.log_target
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def default_log_file=(value)
|
|
90
|
+
previous_default = default_context
|
|
91
|
+
@default_context = previous_default.with_log_target(value)
|
|
92
|
+
active = Thread.current[THREAD_CONTEXT_KEY]
|
|
93
|
+
if active.nil? || active.log_target == previous_default.log_target
|
|
94
|
+
Thread.current[THREAD_CONTEXT_KEY] = @default_context
|
|
95
|
+
end
|
|
96
|
+
value # rubocop:disable Lint/Void -- return assigned log target for symmetry
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def active_log_file
|
|
100
|
+
context.log_target
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def active_log_file=(value)
|
|
104
|
+
current = Thread.current[THREAD_CONTEXT_KEY]
|
|
105
|
+
Thread.current[THREAD_CONTEXT_KEY] = if current
|
|
106
|
+
current.with_log_target(value)
|
|
107
|
+
else
|
|
108
|
+
default_context.with_log_target(value)
|
|
109
|
+
end
|
|
110
|
+
value # rubocop:disable Lint/Void -- return assigned log target for symmetry
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def error_handler
|
|
114
|
+
context.error_handler
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def error_handler=(handler)
|
|
118
|
+
@default_context = default_context.with_error_handler(handler)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private def default_context
|
|
122
|
+
@default_context ||= AppContext.new(
|
|
123
|
+
error_handler: ErrorHandlerFactory.for_cli,
|
|
124
|
+
log_target: nil
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private def extract_env_opts
|
|
129
|
+
require 'shellwords'
|
|
130
|
+
opts_string = ENV['COV_LOUPE_OPTS']
|
|
131
|
+
return [] unless opts_string && !opts_string.empty?
|
|
132
|
+
|
|
133
|
+
begin
|
|
134
|
+
Shellwords.split(opts_string)
|
|
135
|
+
rescue ArgumentError
|
|
136
|
+
[] # Ignore parsing errors
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|