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.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +173 -356
  4. data/docs/ADVANCED_USAGE.md +967 -0
  5. data/docs/ARCHITECTURE.md +79 -0
  6. data/docs/BRANCH_ONLY_COVERAGE.md +81 -0
  7. data/docs/CLI_USAGE.md +637 -0
  8. data/docs/DEVELOPMENT.md +82 -0
  9. data/docs/ERROR_HANDLING.md +93 -0
  10. data/docs/EXAMPLES.md +430 -0
  11. data/docs/INSTALLATION.md +352 -0
  12. data/docs/LIBRARY_API.md +635 -0
  13. data/docs/MCP_INTEGRATION.md +488 -0
  14. data/docs/TROUBLESHOOTING.md +276 -0
  15. data/docs/arch-decisions/001-x-arch-decision.md +93 -0
  16. data/docs/arch-decisions/002-x-arch-decision.md +157 -0
  17. data/docs/arch-decisions/003-x-arch-decision.md +163 -0
  18. data/docs/arch-decisions/004-x-arch-decision.md +199 -0
  19. data/docs/arch-decisions/005-x-arch-decision.md +187 -0
  20. data/docs/arch-decisions/README.md +60 -0
  21. data/docs/presentations/simplecov-mcp-presentation.md +249 -0
  22. data/exe/simplecov-mcp +4 -4
  23. data/lib/simplecov_mcp/app_context.rb +26 -0
  24. data/lib/simplecov_mcp/base_tool.rb +74 -0
  25. data/lib/simplecov_mcp/cli.rb +234 -0
  26. data/lib/simplecov_mcp/cli_config.rb +56 -0
  27. data/lib/simplecov_mcp/commands/base_command.rb +78 -0
  28. data/lib/simplecov_mcp/commands/command_factory.rb +39 -0
  29. data/lib/simplecov_mcp/commands/detailed_command.rb +24 -0
  30. data/lib/simplecov_mcp/commands/list_command.rb +13 -0
  31. data/lib/simplecov_mcp/commands/raw_command.rb +22 -0
  32. data/lib/simplecov_mcp/commands/summary_command.rb +24 -0
  33. data/lib/simplecov_mcp/commands/uncovered_command.rb +26 -0
  34. data/lib/simplecov_mcp/commands/version_command.rb +18 -0
  35. data/lib/simplecov_mcp/constants.rb +22 -0
  36. data/lib/simplecov_mcp/error_handler.rb +124 -0
  37. data/lib/simplecov_mcp/error_handler_factory.rb +31 -0
  38. data/lib/simplecov_mcp/errors.rb +179 -0
  39. data/lib/simplecov_mcp/formatters/source_formatter.rb +148 -0
  40. data/lib/simplecov_mcp/mcp_server.rb +40 -0
  41. data/lib/simplecov_mcp/mode_detector.rb +55 -0
  42. data/lib/simplecov_mcp/model.rb +300 -0
  43. data/lib/simplecov_mcp/option_normalizers.rb +92 -0
  44. data/lib/simplecov_mcp/option_parser_builder.rb +134 -0
  45. data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +50 -0
  46. data/lib/simplecov_mcp/option_parsers/error_helper.rb +109 -0
  47. data/lib/simplecov_mcp/path_relativizer.rb +61 -0
  48. data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +44 -0
  49. data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +16 -0
  50. data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +16 -0
  51. data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +16 -0
  52. data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +16 -0
  53. data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +52 -0
  54. data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +126 -0
  55. data/lib/simplecov_mcp/resolvers/resolver_factory.rb +28 -0
  56. data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +78 -0
  57. data/lib/simplecov_mcp/resultset_loader.rb +136 -0
  58. data/lib/simplecov_mcp/staleness_checker.rb +243 -0
  59. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/all_files_coverage_tool.rb +31 -13
  60. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_detailed_tool.rb +7 -7
  61. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_raw_tool.rb +7 -7
  62. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_summary_tool.rb +7 -7
  63. data/lib/simplecov_mcp/tools/coverage_table_tool.rb +90 -0
  64. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/help_tool.rb +13 -4
  65. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/uncovered_lines_tool.rb +7 -7
  66. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/version_tool.rb +11 -3
  67. data/lib/simplecov_mcp/util.rb +82 -0
  68. data/lib/{simple_cov_mcp → simplecov_mcp}/version.rb +1 -1
  69. data/lib/simplecov_mcp.rb +144 -2
  70. data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
  71. data/spec/TIMESTAMPS.md +48 -0
  72. data/spec/all_files_coverage_tool_spec.rb +29 -25
  73. data/spec/base_tool_spec.rb +11 -10
  74. data/spec/cli/show_default_report_spec.rb +33 -0
  75. data/spec/cli_config_spec.rb +137 -0
  76. data/spec/cli_enumerated_options_spec.rb +68 -0
  77. data/spec/cli_error_spec.rb +105 -47
  78. data/spec/cli_source_spec.rb +82 -23
  79. data/spec/cli_spec.rb +140 -5
  80. data/spec/cli_success_predicate_spec.rb +141 -0
  81. data/spec/cli_table_spec.rb +1 -1
  82. data/spec/cli_usage_spec.rb +10 -26
  83. data/spec/commands/base_command_spec.rb +187 -0
  84. data/spec/commands/command_factory_spec.rb +72 -0
  85. data/spec/commands/detailed_command_spec.rb +48 -0
  86. data/spec/commands/raw_command_spec.rb +46 -0
  87. data/spec/commands/summary_command_spec.rb +47 -0
  88. data/spec/commands/uncovered_command_spec.rb +49 -0
  89. data/spec/constants_spec.rb +61 -0
  90. data/spec/coverage_table_tool_spec.rb +17 -33
  91. data/spec/error_handler_spec.rb +22 -13
  92. data/spec/error_mode_spec.rb +143 -0
  93. data/spec/errors_edge_cases_spec.rb +239 -0
  94. data/spec/errors_stale_spec.rb +2 -2
  95. data/spec/file_based_mcp_tools_spec.rb +99 -0
  96. data/spec/fixtures/project1/lib/bar.rb +0 -1
  97. data/spec/fixtures/project1/lib/foo.rb +0 -1
  98. data/spec/help_tool_spec.rb +11 -17
  99. data/spec/integration_spec.rb +845 -0
  100. data/spec/logging_fallback_spec.rb +128 -0
  101. data/spec/mcp_logging_spec.rb +44 -0
  102. data/spec/mcp_server_integration_spec.rb +23 -0
  103. data/spec/mcp_server_spec.rb +15 -4
  104. data/spec/mode_detector_spec.rb +148 -0
  105. data/spec/model_error_handling_spec.rb +210 -0
  106. data/spec/model_staleness_spec.rb +40 -10
  107. data/spec/option_normalizers_spec.rb +204 -0
  108. data/spec/option_parsers/env_options_parser_spec.rb +233 -0
  109. data/spec/option_parsers/error_helper_spec.rb +222 -0
  110. data/spec/path_relativizer_spec.rb +83 -0
  111. data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
  112. data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
  113. data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
  114. data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
  115. data/spec/presenters/project_coverage_presenter_spec.rb +86 -0
  116. data/spec/resolvers/coverage_line_resolver_spec.rb +57 -0
  117. data/spec/resolvers/resolver_factory_spec.rb +61 -0
  118. data/spec/resolvers/resultset_path_resolver_spec.rb +55 -0
  119. data/spec/resultset_loader_spec.rb +167 -0
  120. data/spec/shared_examples/README.md +115 -0
  121. data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
  122. data/spec/shared_examples/file_based_mcp_tools.rb +174 -0
  123. data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
  124. data/spec/simple_cov_mcp_module_spec.rb +16 -0
  125. data/spec/simplecov_mcp_model_spec.rb +340 -9
  126. data/spec/simplecov_mcp_opts_spec.rb +182 -0
  127. data/spec/spec_helper.rb +147 -4
  128. data/spec/staleness_checker_spec.rb +373 -0
  129. data/spec/staleness_more_spec.rb +16 -13
  130. data/spec/support/mcp_runner.rb +64 -0
  131. data/spec/tools_error_handling_spec.rb +144 -0
  132. data/spec/util_spec.rb +109 -34
  133. data/spec/version_spec.rb +117 -9
  134. data/spec/version_tool_spec.rb +131 -10
  135. metadata +120 -63
  136. data/lib/simple_cov/mcp.rb +0 -9
  137. data/lib/simple_cov_mcp/base_tool.rb +0 -70
  138. data/lib/simple_cov_mcp/cli.rb +0 -390
  139. data/lib/simple_cov_mcp/error_handler.rb +0 -131
  140. data/lib/simple_cov_mcp/error_handler_factory.rb +0 -38
  141. data/lib/simple_cov_mcp/errors.rb +0 -176
  142. data/lib/simple_cov_mcp/mcp_server.rb +0 -30
  143. data/lib/simple_cov_mcp/model.rb +0 -104
  144. data/lib/simple_cov_mcp/staleness_checker.rb +0 -125
  145. data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +0 -61
  146. data/lib/simple_cov_mcp/util.rb +0 -122
  147. data/lib/simple_cov_mcp.rb +0 -102
  148. data/spec/coverage_detailed_tool_spec.rb +0 -36
  149. data/spec/coverage_raw_tool_spec.rb +0 -32
  150. data/spec/coverage_summary_tool_spec.rb +0 -39
  151. data/spec/legacy_shim_spec.rb +0 -13
  152. data/spec/uncovered_lines_tool_spec.rb +0 -33
@@ -1,176 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SimpleCovMcp
4
- # Base error class for all SimpleCov MCP errors
5
- class Error < StandardError
6
- attr_reader :original_error
7
-
8
- def initialize(message = nil, original_error = nil)
9
- @original_error = original_error
10
- super(message)
11
- end
12
-
13
- def user_friendly_message
14
- message
15
- end
16
- end
17
-
18
- # Configuration or setup related errors
19
- class ConfigurationError < Error
20
- def user_friendly_message
21
- "Configuration error: #{message}"
22
- end
23
- end
24
-
25
- # File or path related errors
26
- class FileError < Error
27
- def user_friendly_message
28
- "File error: #{message}"
29
- end
30
- end
31
-
32
- # More specific file errors
33
- class FileNotFoundError < FileError; end
34
- class FilePermissionError < FileError; end
35
- class NotAFileError < FileError; end
36
- class ResultsetNotFoundError < FileError; end
37
-
38
- # Coverage data related errors
39
- class CoverageDataError < Error
40
- def user_friendly_message
41
- "Coverage data error: #{message}"
42
- end
43
- end
44
-
45
- # Coverage data is present but appears stale compared to source files
46
- class CoverageDataStaleError < CoverageDataError
47
- attr_reader :file_path, :file_mtime, :cov_timestamp, :src_len, :cov_len, :resultset_path
48
-
49
- def initialize(message = nil, original_error = nil, file_path: nil, file_mtime: nil, cov_timestamp: nil, src_len: nil, cov_len: nil, resultset_path: nil)
50
- super(message, original_error)
51
- @file_path = file_path
52
- @file_mtime = file_mtime
53
- @cov_timestamp = cov_timestamp
54
- @src_len = src_len
55
- @cov_len = cov_len
56
- @resultset_path = resultset_path
57
- end
58
-
59
- def user_friendly_message
60
- base = "Coverage data stale: #{message || default_message}"
61
- base + build_details
62
- end
63
-
64
- private
65
-
66
- def default_message
67
- fp = file_path || 'file'
68
- "Coverage data appears stale for #{fp}"
69
- end
70
-
71
- def build_details
72
- file_utc, file_local = format_time_both(@file_mtime)
73
- cov_utc, cov_local = format_epoch_both(@cov_timestamp)
74
- delta_str = format_delta_seconds(@file_mtime, @cov_timestamp)
75
- details = []
76
- details << "\nFile - time: #{file_utc || 'not found'} (local #{file_local || 'n/a'}), lines: #{@src_len}"
77
- details << "\nCoverage - time: #{cov_utc || 'not found'} (local #{cov_local || 'n/a'}), lines: #{@cov_len}"
78
- details << "\nDelta - file is #{delta_str} newer than coverage" if delta_str
79
- details << "\nResultset - #{@resultset_path}" if @resultset_path
80
- details.join
81
- end
82
-
83
- def format_epoch_both(epoch_seconds)
84
- return [nil, nil] unless epoch_seconds
85
- t = Time.at(epoch_seconds.to_i)
86
- [t.utc.iso8601, t.getlocal.iso8601]
87
- rescue StandardError
88
- [epoch_seconds.to_s, epoch_seconds.to_s]
89
- end
90
-
91
- def format_time_both(time)
92
- return [nil, nil] unless time
93
- t = time.is_a?(Time) ? time : Time.parse(time.to_s)
94
- [t.utc.iso8601, t.getlocal.iso8601]
95
- rescue StandardError
96
- [time.to_s, time.to_s]
97
- end
98
-
99
- def format_delta_seconds(file_mtime, cov_timestamp)
100
- return nil unless file_mtime && cov_timestamp
101
- seconds = file_mtime.to_i - cov_timestamp.to_i
102
- sign = seconds >= 0 ? '+' : '-'
103
- "#{sign}#{seconds.abs}s"
104
- rescue StandardError
105
- nil
106
- end
107
- end
108
-
109
- # Project-level stale coverage (global) — coverage timestamp older than
110
- # one or more source files, or new tracked files missing from coverage.
111
- class CoverageDataProjectStaleError < CoverageDataError
112
- attr_reader :cov_timestamp, :newer_files, :missing_files, :deleted_files, :resultset_path
113
-
114
- def initialize(message = nil, original_error = nil, cov_timestamp: nil, newer_files: [], missing_files: [], deleted_files: [], resultset_path: nil)
115
- super(message, original_error)
116
- @cov_timestamp = cov_timestamp
117
- @newer_files = Array(newer_files)
118
- @missing_files = Array(missing_files)
119
- @deleted_files = Array(deleted_files)
120
- @resultset_path = resultset_path
121
- end
122
-
123
- def user_friendly_message
124
- base = "Coverage data stale (project): #{message || default_message}"
125
- base + build_details
126
- end
127
-
128
- private
129
-
130
- def default_message
131
- 'Coverage data appears stale for project'
132
- end
133
-
134
- def build_details
135
- cov_utc, cov_local = format_epoch_both(@cov_timestamp)
136
- parts = []
137
- parts << "\nCoverage - time: #{cov_utc || 'not found'} (local #{cov_local || 'n/a'})"
138
- unless @newer_files.empty?
139
- parts << "\nNewer files (#{@newer_files.size}):"
140
- parts.concat(@newer_files.first(10).map { |f| " - #{f}" })
141
- parts << " ..." if @newer_files.size > 10
142
- end
143
- unless @missing_files.empty?
144
- parts << "\nMissing files (new in project, not in coverage, #{@missing_files.size}):"
145
- parts.concat(@missing_files.first(10).map { |f| " - #{f}" })
146
- parts << " ..." if @missing_files.size > 10
147
- end
148
- unless @deleted_files.empty?
149
- parts << "\nCoverage-only files (deleted or moved in project, #{@deleted_files.size}):"
150
- parts.concat(@deleted_files.first(10).map { |f| " - #{f}" })
151
- parts << " ..." if @deleted_files.size > 10
152
- end
153
- parts << "\nResultset - #{@resultset_path}" if @resultset_path
154
- parts.join
155
- end
156
-
157
- def format_epoch_both(epoch_seconds)
158
- return [nil, nil] unless epoch_seconds
159
- t = Time.at(epoch_seconds.to_i)
160
- [t.utc.iso8601, t.getlocal.iso8601]
161
- rescue StandardError
162
- [epoch_seconds.to_s, epoch_seconds.to_s]
163
- end
164
- end
165
-
166
- # Command line usage errors
167
- class UsageError < Error
168
- def self.for_subcommand(usage_fragment)
169
- new("Usage: simplecov-mcp #{usage_fragment}")
170
- end
171
-
172
- def user_friendly_message
173
- message
174
- end
175
- end
176
- end
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SimpleCovMcp
4
- class MCPServer
5
- def initialize
6
- # Configure error handling for MCP server mode using the factory
7
- SimpleCovMcp.error_handler = ErrorHandlerFactory.for_mcp_server
8
- end
9
-
10
- def run
11
- tools = [
12
- Tools::AllFilesCoverageTool,
13
- Tools::CoverageDetailedTool,
14
- Tools::CoverageRawTool,
15
- Tools::CoverageSummaryTool,
16
- Tools::UncoveredLinesTool,
17
- Tools::CoverageTableTool,
18
- Tools::HelpTool,
19
- Tools::VersionTool
20
- ]
21
-
22
- server = ::MCP::Server.new(
23
- name: 'simplecov-mcp',
24
- version: SimpleCovMcp::VERSION,
25
- tools: tools
26
- )
27
- ::MCP::Server::Transports::StdioTransport.new(server).open
28
- end
29
- end
30
- end
@@ -1,104 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'util'
4
- require_relative 'errors'
5
- require_relative 'staleness_checker'
6
-
7
- module SimpleCovMcp
8
- class CoverageModel
9
- # Create a CoverageModel
10
- #
11
- # Params:
12
- # - root: project root directory (default '.')
13
- # - resultset: path or directory to .resultset.json
14
- # - staleness: 'off' or 'error' (default 'off'). When 'error', raises
15
- # stale errors if sources are newer than coverage or line counts mismatch.
16
- # - tracked_globs: only used for all_files project-level staleness.
17
- def initialize(root: '.', resultset: nil, staleness: 'off', tracked_globs: nil)
18
- @root = File.absolute_path(root || '.')
19
- @resultset = resultset
20
- @checker = StalenessChecker.new(root: @root, resultset: @resultset, mode: staleness, tracked_globs: tracked_globs)
21
- begin
22
- @cov = CovUtil.load_latest_coverage(@root, resultset: resultset)
23
- rescue Errno::ENOENT => e
24
- raise FileError.new("Coverage data not found at #{resultset || @root}")
25
- rescue JSON::ParserError => e
26
- raise CoverageDataError.new("Invalid coverage data format")
27
- rescue => e
28
- raise CoverageDataError.new("Failed to load coverage data: #{e.message}")
29
- end
30
- end
31
-
32
- # Returns { 'file' => <absolute_path>, 'lines' => [hits|nil,...] }
33
- def raw_for(path)
34
- file_abs, coverage_lines = resolve(path)
35
- { 'file' => file_abs, 'lines' => coverage_lines }
36
- end
37
-
38
- # Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, 'pct'=>} }
39
- def summary_for(path)
40
- file_abs, coverage_lines = resolve(path)
41
- { 'file' => file_abs, 'summary' => CovUtil.summary(coverage_lines) }
42
- end
43
-
44
- # Returns { 'file' => <absolute_path>, 'uncovered' => [line,...], 'summary' => {...} }
45
- def uncovered_for(path)
46
- file_abs, coverage_lines = resolve(path)
47
- { 'file' => file_abs, 'uncovered' => CovUtil.uncovered(coverage_lines), 'summary' => CovUtil.summary(coverage_lines) }
48
- end
49
-
50
- # Returns { 'file' => <absolute_path>, 'lines' => [{'line'=>,'hits'=>,'covered'=>},...], 'summary' => {...} }
51
- def detailed_for(path)
52
- file_abs, coverage_lines = resolve(path)
53
- { 'file' => file_abs, 'lines' => CovUtil.detailed(coverage_lines), 'summary' => CovUtil.summary(coverage_lines) }
54
- end
55
-
56
- # Returns [ { 'file' =>, 'covered' =>, 'total' =>, 'percentage' =>, 'stale' => }, ... ]
57
- def all_files(sort_order: :ascending, check_stale: !@checker.off?, tracked_globs: nil)
58
- stale_checker = StalenessChecker.new(root: @root, resultset: @resultset, mode: 'off', tracked_globs: tracked_globs)
59
- rows = @cov.map do |abs_path, data|
60
- next unless data['lines'].is_a?(Array)
61
- s = CovUtil.summary(data['lines'])
62
- stale = stale_checker.stale_for_file?(abs_path, data['lines'])
63
- { 'file' => abs_path, 'covered' => s['covered'], 'total' => s['total'], 'percentage' => s['pct'], 'stale' => stale }
64
- end.compact
65
-
66
- if check_stale
67
- StalenessChecker.new(root: @root, resultset: @resultset, mode: 'error', tracked_globs: tracked_globs)
68
- .check_project!(@cov)
69
- end
70
-
71
- rows.sort! do |a, b|
72
- pct_cmp = (sort_order.to_s == 'descending') ? (b['percentage'] <=> a['percentage']) : (a['percentage'] <=> b['percentage'])
73
- pct_cmp == 0 ? (a['file'] <=> b['file']) : pct_cmp
74
- end
75
- rows
76
- end
77
-
78
- private
79
-
80
- def resolve(path)
81
- file_abs = File.absolute_path(path, @root)
82
- coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
83
- @checker.check_file!(file_abs, coverage_lines) unless @checker.off?
84
- if coverage_lines.nil?
85
- raise FileError.new("No coverage data found for file: #{path}")
86
- end
87
- [file_abs, coverage_lines]
88
- rescue Errno::ENOENT => e
89
- raise FileNotFoundError.new("File not found: #{path}")
90
- end
91
-
92
- # staleness handled by StalenessChecker
93
-
94
- def check_all_files_staleness!(cov_timestamp, tracked_globs: nil)
95
- # handled by StalenessChecker
96
- end
97
-
98
- def rel_to_root(path)
99
- Pathname.new(path).relative_path_from(Pathname.new(File.absolute_path(@root))).to_s
100
- end
101
-
102
- # Detailed stale message construction moved to CoverageDataStaleError
103
- end
104
- end
@@ -1,125 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'time'
4
-
5
- module SimpleCovMcp
6
- # Lightweight service object to check staleness of coverage vs. sources
7
- class StalenessChecker
8
- MODES = %w[off error].freeze
9
-
10
- def initialize(root:, resultset:, mode: 'off', tracked_globs: nil)
11
- @root = File.absolute_path(root || '.')
12
- @resultset = resultset
13
- @mode = (mode || 'off').to_s
14
- @tracked_globs = tracked_globs
15
- @cov_timestamp = nil
16
- @resultset_path = nil
17
- end
18
-
19
- def off?
20
- @mode == 'off'
21
- end
22
-
23
- # Raise CoverageDataStaleError if stale (only in error mode)
24
- def check_file!(file_abs, coverage_lines)
25
- return if off?
26
- ts = coverage_timestamp
27
- fm = File.mtime(file_abs) if File.file?(file_abs)
28
- cov_len = coverage_lines.respond_to?(:length) ? coverage_lines.length : 0
29
- src_len = safe_count_lines(file_abs)
30
- if (fm && fm.to_i > ts.to_i) || (cov_len.positive? && src_len != cov_len)
31
- raise CoverageDataStaleError.new(
32
- nil,
33
- nil,
34
- file_path: rel(file_abs),
35
- file_mtime: fm,
36
- cov_timestamp: ts,
37
- src_len: src_len,
38
- cov_len: cov_len,
39
- resultset_path: resultset_path
40
- )
41
- end
42
- end
43
-
44
- # Compute whether a specific file appears stale relative to coverage.
45
- # Ignores mode and never raises; returns true when:
46
- # - the file is missing/deleted, or
47
- # - the file mtime is newer than the coverage timestamp, or
48
- # - the source line count differs from the coverage lines array length (when present).
49
- def stale_for_file?(file_abs, coverage_lines)
50
- ts = coverage_timestamp
51
- return true unless File.file?(file_abs)
52
-
53
- fm = File.mtime(file_abs)
54
- cov_len = coverage_lines.respond_to?(:length) ? coverage_lines.length : 0
55
- src_len = safe_count_lines(file_abs)
56
- (fm && fm.to_i > ts.to_i) || (cov_len.positive? && src_len != cov_len)
57
- rescue StandardError
58
- # Be conservative: if we cannot determine, mark as stale
59
- true
60
- end
61
-
62
- # Raise CoverageDataProjectStaleError if any covered file is newer or if
63
- # tracked files are missing from coverage, or coverage includes deleted files.
64
- def check_project!(coverage_map)
65
- return if off?
66
- ts = coverage_timestamp
67
- newer = []
68
- deleted = []
69
- coverage_files = coverage_map.keys
70
- coverage_files.each do |abs|
71
- if File.file?(abs)
72
- newer << rel(abs) if File.mtime(abs).to_i > ts.to_i
73
- else
74
- deleted << rel(abs)
75
- end
76
- end
77
-
78
- missing = []
79
- if @tracked_globs && !Array(@tracked_globs).empty?
80
- patterns = Array(@tracked_globs).map { |g| File.absolute_path(g, @root) }
81
- tracked = patterns.flat_map { |p| Dir.glob(p, File::FNM_EXTGLOB | File::FNM_PATHNAME) }
82
- .select { |p| File.file?(p) }
83
- covered_set = coverage_files.to_set rescue coverage_files
84
- tracked.each do |abs|
85
- missing << rel(abs) unless covered_set.include?(abs)
86
- end
87
- end
88
-
89
- if !newer.empty? || !missing.empty? || !deleted.empty?
90
- raise CoverageDataProjectStaleError.new(
91
- nil,
92
- nil,
93
- cov_timestamp: ts,
94
- newer_files: newer,
95
- missing_files: missing,
96
- deleted_files: deleted,
97
- resultset_path: resultset_path
98
- )
99
- end
100
- end
101
-
102
- private
103
-
104
- def coverage_timestamp
105
- @cov_timestamp ||= CovUtil.latest_timestamp(@root, resultset: @resultset)
106
- end
107
-
108
- def resultset_path
109
- @resultset_path ||= CovUtil.find_resultset(@root, resultset: @resultset)
110
- rescue StandardError
111
- nil
112
- end
113
-
114
- def safe_count_lines(path)
115
- return 0 unless File.file?(path)
116
- File.foreach(path).count
117
- rescue StandardError
118
- 0
119
- end
120
-
121
- def rel(path)
122
- Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
123
- end
124
- end
125
- end
@@ -1,61 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'stringio'
4
- require_relative '../cli'
5
- require_relative '../base_tool'
6
-
7
- module SimpleCovMcp
8
- module Tools
9
- class CoverageTableTool < BaseTool
10
- description <<~DESC
11
- Use this when a user wants the plain text coverage table exactly like `simplecov-mcp --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(
18
- type: 'object',
19
- additionalProperties: false,
20
- properties: {
21
- root: {
22
- type: 'string',
23
- description: 'Project root used to resolve relative inputs.',
24
- default: '.'
25
- },
26
- resultset: {
27
- type: 'string',
28
- description: 'Path to the SimpleCov .resultset.json file.'
29
- },
30
- sort_order: {
31
- type: 'string',
32
- description: "Sort order for the printed coverage table (ascending or descending).",
33
- default: 'ascending',
34
- enum: ['ascending', 'descending']
35
- },
36
- stale: {
37
- type: 'string',
38
- description: "How to handle missing/outdated coverage data. 'off' skips checks; 'error' raises.",
39
- enum: ['off', 'error'],
40
- default: 'off'
41
- }
42
- }
43
- )
44
-
45
- class << self
46
- def call(root: '.', resultset: nil, sort_order: 'ascending', stale: 'off', server_context:)
47
- # Capture the output of the CLI's table report while honoring CLI options
48
- output = StringIO.new
49
- cli = CoverageCLI.new
50
- cli.instance_variable_set(:@root, root || '.')
51
- cli.instance_variable_set(:@resultset, resultset)
52
- cli.instance_variable_set(:@stale_mode, (stale || 'off').to_s)
53
- cli.show_default_report(sort_order: sort_order.to_sym, output: output)
54
- ::MCP::Tool::Response.new([{ type: 'text', text: output.string }])
55
- rescue => e
56
- handle_mcp_error(e, 'CoverageTableTool')
57
- end
58
- end
59
- end
60
- end
61
- end
@@ -1,122 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SimpleCovMcp
4
- RESULTSET_CANDIDATES = [
5
- '.resultset.json',
6
- 'coverage/.resultset.json',
7
- 'tmp/.resultset.json'
8
- ].freeze
9
-
10
- module CovUtil
11
- module_function
12
-
13
-
14
- def log(msg)
15
- path = File.expand_path('~/simplecov_mcp.log')
16
- File.open(path, 'a') { |f| f.puts "[#{Time.now.iso8601}] #{msg}" }
17
- rescue StandardError
18
- # ignore logging failures
19
- end
20
-
21
- def find_resultset(root, resultset: nil)
22
- if resultset && !resultset.to_s.empty?
23
- path = File.absolute_path(resultset, root)
24
- if (resolved = resolve_resultset_candidate(path, strict: true))
25
- return resolved
26
- end
27
- end
28
-
29
- if (env = ENV['SIMPLECOV_RESULTSET']) && !env.empty?
30
- path = File.absolute_path(env, root)
31
- if (resolved = resolve_resultset_candidate(path, strict: false))
32
- return resolved
33
- end
34
- end
35
- RESULTSET_CANDIDATES
36
- .map { |p| File.absolute_path(p, root) }
37
- .find { |p| File.file?(p) } or
38
- raise "Could not find .resultset.json under #{root.inspect}; run tests or set SIMPLECOV_RESULTSET"
39
- end
40
-
41
- # returns { abs_path => {'lines' => [hits|nil,...]} }
42
- def load_latest_coverage(root, resultset: nil)
43
- rs = find_resultset(root, resultset: resultset)
44
- raw = JSON.parse(File.read(rs))
45
- _suite, data = raw.max_by { |_k, v| (v['timestamp'] || v['created_at'] || 0).to_i }
46
- cov = data['coverage'] or raise "No 'coverage' key found in resultset file: #{rs}"
47
- cov.transform_keys { |k| File.absolute_path(k, root) }
48
- end
49
-
50
- # Returns the timestamp (Integer seconds) for the latest coverage entry
51
- # in the resultset. Used for staleness checks against source mtimes.
52
- def latest_timestamp(root, resultset: nil)
53
- rs = find_resultset(root, resultset: resultset)
54
- raw = JSON.parse(File.read(rs))
55
- _suite, data = raw.max_by { |_k, v| (v['timestamp'] || v['created_at'] || 0).to_i }
56
- (data['timestamp'] || data['created_at'] || 0).to_i
57
- end
58
-
59
- def resolve_resultset_candidate(path, strict:)
60
- return path if File.file?(path)
61
- if File.directory?(path)
62
- candidate = File.join(path, '.resultset.json')
63
- return candidate if File.file?(candidate)
64
- raise "No .resultset.json found in directory: #{path}" if strict
65
- return nil
66
- end
67
- raise "Specified resultset not found: #{path}" if strict
68
- nil
69
- end
70
-
71
- def lookup_lines(cov, file_abs)
72
- if (h = cov[file_abs]) && h['lines'].is_a?(Array)
73
- return h['lines']
74
- end
75
-
76
- # try without current working directory prefix
77
- cwd = Dir.pwd
78
- without = file_abs.sub(/\A#{Regexp.escape(cwd)}\//, '')
79
- if (h = cov[without]) && h['lines'].is_a?(Array)
80
- return h['lines']
81
- end
82
-
83
- # fallback: basename match
84
- base = File.basename(file_abs)
85
- kv = cov.find { |k, v| File.basename(k) == base && v['lines'].is_a?(Array) }
86
- kv and return kv[1]['lines']
87
-
88
- raise "No coverage entry found for #{file_abs}"
89
- end
90
-
91
- def summary(arr)
92
- total = 0
93
- covered = 0
94
- arr.each do |hits|
95
- next if hits.nil?
96
- total += 1
97
- covered += 1 if hits.to_i > 0
98
- end
99
- pct = total.zero? ? 100.0 : ((covered.to_f * 100.0 / total) * 100).round / 100.0
100
- { 'covered' => covered, 'total' => total, 'pct' => pct }
101
- end
102
-
103
- def uncovered(arr)
104
- out = []
105
- arr.each_with_index do |hits, i|
106
- next if hits.nil?
107
- out << (i + 1) if hits.to_i.zero?
108
- end
109
- out
110
- end
111
-
112
- def detailed(arr)
113
- rows = []
114
- arr.each_with_index do |hits, i|
115
- next if hits.nil?
116
- h = hits.to_i
117
- rows << { 'line' => i + 1, 'hits' => h, 'covered' => h.positive? }
118
- end
119
- rows
120
- end
121
- end
122
- end