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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CovLoupe
4
+ VERSION = '3.0.0'
5
+ 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