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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module CovLoupe
6
+ module Resolvers
7
+ class ResultsetPathResolver
8
+ DEFAULT_CANDIDATES = [
9
+ '.resultset.json',
10
+ 'coverage/.resultset.json',
11
+ 'tmp/.resultset.json'
12
+ ].freeze
13
+
14
+ def initialize(root: Dir.pwd, candidates: DEFAULT_CANDIDATES)
15
+ @root = root
16
+ @candidates = candidates
17
+ end
18
+
19
+ def find_resultset(resultset: nil)
20
+ if resultset && !resultset.empty?
21
+ path = normalize_resultset_path(resultset)
22
+ if (resolved = resolve_candidate(path, strict: true))
23
+ return resolved
24
+ end
25
+ end
26
+
27
+ resolve_fallback or raise_not_found_error
28
+ end
29
+
30
+ private def resolve_candidate(path, strict:)
31
+ return path if File.file?(path)
32
+ return resolve_directory(path) if File.directory?(path)
33
+
34
+ raise_not_found_error_for_file(path) if strict
35
+ nil
36
+ end
37
+
38
+ private def resolve_directory(path)
39
+ candidate = File.join(path, '.resultset.json')
40
+ return candidate if File.file?(candidate)
41
+
42
+ raise "No .resultset.json found in directory: #{path}"
43
+ end
44
+
45
+ private def raise_not_found_error_for_file(path)
46
+ raise "Specified resultset not found: #{path}"
47
+ end
48
+
49
+ private def resolve_fallback
50
+ @candidates
51
+ .map { |p| File.absolute_path(p, @root) }
52
+ .find { |p| File.file?(p) }
53
+ end
54
+
55
+ private def normalize_resultset_path(resultset)
56
+ candidate = Pathname.new(resultset)
57
+ return candidate.cleanpath.to_s if candidate.absolute?
58
+
59
+ expanded = File.expand_path(resultset, Dir.pwd)
60
+ return expanded if within_root?(expanded)
61
+
62
+ File.absolute_path(resultset, @root)
63
+ end
64
+
65
+ private def within_root?(path)
66
+ normalized_root = Pathname.new(@root).cleanpath.to_s
67
+ root_with_sep = normalized_root.end_with?(File::SEPARATOR) ? normalized_root : "#{normalized_root}#{File::SEPARATOR}"
68
+ path == normalized_root || path.start_with?(root_with_sep)
69
+ end
70
+
71
+ private def raise_not_found_error
72
+ raise "Could not find .resultset.json under #{@root.inspect}; run tests or set --resultset option"
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ require_relative 'errors'
7
+ require_relative 'util'
8
+
9
+ module CovLoupe
10
+ class ResultsetLoader
11
+ Result = Struct.new(:coverage_map, :timestamp, :suite_names, keyword_init: true)
12
+ SuiteEntry = Struct.new(:name, :coverage, :timestamp, keyword_init: true)
13
+
14
+ class << self
15
+ def load(resultset_path:)
16
+ raw = JSON.load_file(resultset_path)
17
+
18
+
19
+ suites = extract_suite_entries(raw, resultset_path)
20
+ raise CoverageDataError, "No test suite with coverage data found in resultset file: #{resultset_path}" if suites.empty?
21
+
22
+ coverage_map = build_coverage_map(suites, resultset_path)
23
+ Result.new(
24
+ coverage_map: coverage_map,
25
+ timestamp: compute_combined_timestamp(suites),
26
+ suite_names: suites.map(&:name)
27
+ )
28
+ end
29
+
30
+ private def extract_suite_entries(raw, resultset_path)
31
+ raw
32
+ .select { |_, data| data.is_a?(Hash) && data.key?('coverage') && !data['coverage'].nil? }
33
+ .map do |name, data|
34
+ SuiteEntry.new(
35
+ name: name.to_s,
36
+ coverage: normalize_suite_coverage(data['coverage'], suite_name: name,
37
+ resultset_path: resultset_path),
38
+ timestamp: normalize_coverage_timestamp(data['timestamp'], data['created_at'])
39
+ )
40
+ end
41
+ end
42
+
43
+ private def build_coverage_map(suites, resultset_path)
44
+ return suites.first&.coverage if suites.length == 1
45
+
46
+ merge_suite_coverages(suites, resultset_path)
47
+ end
48
+
49
+ private def normalize_suite_coverage(coverage, suite_name:, resultset_path:)
50
+ unless coverage.is_a?(Hash)
51
+ raise CoverageDataError, "Invalid coverage data structure for suite #{suite_name.inspect} in resultset file: #{resultset_path}"
52
+ end
53
+
54
+ needs_adaptation = coverage.values.any? { |value| value.is_a?(Array) }
55
+ return coverage unless needs_adaptation
56
+
57
+ coverage.transform_values do |value|
58
+ value.is_a?(Array) ? { 'lines' => value } : value
59
+ end
60
+ end
61
+
62
+ private def merge_suite_coverages(suites, resultset_path)
63
+ require_simplecov_for_merge!(resultset_path)
64
+ log_duplicate_suite_names(suites)
65
+
66
+ suites.reduce(nil) do |memo, suite|
67
+ coverage = suite.coverage
68
+ memo ?
69
+ SimpleCov::Combine.combine(SimpleCov::Combine::ResultsCombiner, memo, coverage) :
70
+ coverage
71
+ end
72
+ end
73
+
74
+ private def require_simplecov_for_merge!(resultset_path)
75
+ require 'simplecov'
76
+ rescue LoadError
77
+ raise CoverageDataError, "Multiple coverage suites detected in #{resultset_path}, but the simplecov gem could not be loaded. Install simplecov to enable suite merging."
78
+ end
79
+
80
+ private def log_duplicate_suite_names(suites)
81
+ grouped = suites.group_by(&:name)
82
+ duplicates = grouped.select { |_, entries| entries.length > 1 }.keys
83
+ return if duplicates.empty?
84
+
85
+ message = "Merging duplicate coverage suites for #{duplicates.join(', ')}"
86
+ CovUtil.safe_log(message)
87
+ end
88
+
89
+ private def compute_combined_timestamp(suites)
90
+ suites.map(&:timestamp).compact.max.to_i
91
+ end
92
+
93
+ private def normalize_coverage_timestamp(timestamp_value, created_at_value)
94
+ raw = timestamp_value.nil? ? created_at_value : timestamp_value
95
+ return 0 if raw.nil?
96
+
97
+ case raw
98
+ when Integer
99
+ raw
100
+ when Float, Time
101
+ raw.to_i
102
+ when String
103
+ normalize_string_timestamp(raw)
104
+ else
105
+ log_timestamp_warning(raw)
106
+ 0
107
+ end
108
+ rescue => e
109
+ log_timestamp_warning(raw, e)
110
+ 0
111
+ end
112
+
113
+ private def normalize_string_timestamp(value)
114
+ str = value.strip
115
+ return 0 if str.empty?
116
+
117
+ if str.match?(/\A-?\d+(\.\d+)?\z/)
118
+ str.to_f.to_i
119
+ else
120
+ Time.parse(str).to_i
121
+ end
122
+ end
123
+
124
+ private def log_timestamp_warning(raw_value, error = nil)
125
+ message = "Coverage resultset timestamp could not be parsed: #{raw_value.inspect}"
126
+ message = "#{message} (#{error.message})" if error
127
+ CovUtil.safe_log(message)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'pathname'
5
+ require_relative 'errors'
6
+ require_relative 'util'
7
+
8
+ module CovLoupe
9
+ # Lightweight service object to check staleness of coverage vs. sources
10
+ class StalenessChecker
11
+ MODES = [:off, :error].freeze
12
+
13
+ def initialize(root:, resultset:, mode: :off, tracked_globs: nil, timestamp: nil)
14
+ @root = File.absolute_path(root || '.')
15
+ @resultset = resultset
16
+ @mode = (mode || :off).to_sym
17
+ @tracked_globs = tracked_globs
18
+ @cov_timestamp = timestamp
19
+ @resultset_path = nil
20
+ end
21
+
22
+ def off?
23
+ @mode == :off
24
+ end
25
+
26
+ # Raise CoverageDataStaleError if stale (only in error mode)
27
+ def check_file!(file_abs, coverage_lines)
28
+ return if off?
29
+
30
+ d = compute_file_staleness_details(file_abs, coverage_lines)
31
+ # For single-file checks, missing files with recorded coverage count as stale
32
+ # via length mismatch; project-level checks also handle deleted files explicitly.
33
+ if d[:newer] || d[:len_mismatch]
34
+ raise CoverageDataStaleError.new(
35
+ nil,
36
+ nil,
37
+ file_path: rel(file_abs),
38
+ file_mtime: d[:file_mtime],
39
+ cov_timestamp: d[:coverage_timestamp],
40
+ src_len: d[:src_len],
41
+ cov_len: d[:cov_len],
42
+ resultset_path: resultset_path
43
+ )
44
+ end
45
+ end
46
+
47
+ # Compute whether a specific file appears stale relative to coverage.
48
+ # Ignores mode and never raises; returns true when:
49
+ # - the file is missing/deleted, or
50
+ # - the file mtime is newer than the coverage timestamp, or
51
+ # - the source line count differs from the coverage lines array length (when present).
52
+ def stale_for_file?(file_abs, coverage_lines)
53
+ d = compute_file_staleness_details(file_abs, coverage_lines)
54
+ return 'M' unless d[:exists]
55
+ return 'T' if d[:newer]
56
+ return 'L' if d[:len_mismatch]
57
+
58
+ false
59
+ end
60
+
61
+ # Raise CoverageDataProjectStaleError if any covered file is newer or if
62
+ # tracked files are missing from coverage, or coverage includes deleted files.
63
+ def check_project!(coverage_map)
64
+ return if off?
65
+
66
+ ts = coverage_timestamp
67
+ coverage_files = coverage_map.keys
68
+
69
+ newer, deleted = compute_newer_and_deleted_files(coverage_files, ts)
70
+ missing = compute_missing_files(coverage_files)
71
+
72
+ return if newer.empty? && missing.empty? && deleted.empty?
73
+
74
+ raise CoverageDataProjectStaleError.new(
75
+ nil,
76
+ nil,
77
+ cov_timestamp: ts,
78
+ newer_files: newer,
79
+ missing_files: missing,
80
+ deleted_files: deleted,
81
+ resultset_path: resultset_path
82
+ )
83
+ end
84
+
85
+ private def compute_newer_and_deleted_files(coverage_files, timestamp)
86
+ existing, deleted_abs = coverage_files.partition { |abs| File.file?(abs) }
87
+
88
+ newer = existing
89
+ .select { |abs| File.mtime(abs).to_i > timestamp.to_i }
90
+ .map { |abs| rel(abs) }
91
+ deleted = deleted_abs.map { |abs| rel(abs) }
92
+
93
+ [newer, deleted]
94
+ end
95
+
96
+ # Identifies tracked files that are missing from coverage.
97
+ # Returns array of relative paths for files matched by tracked_globs but not in coverage.
98
+ private def compute_missing_files(coverage_files)
99
+ return [] unless @tracked_globs && Array(@tracked_globs).any?
100
+
101
+ patterns = Array(@tracked_globs).map { |g| File.absolute_path(g, @root) }
102
+ tracked = patterns
103
+ .flat_map { |p| Dir.glob(p, File::FNM_EXTGLOB | File::FNM_PATHNAME) }
104
+ .select { |p| File.file?(p) }
105
+
106
+ covered_set = coverage_files.to_set
107
+ tracked.reject { |abs| covered_set.include?(abs) }.map { |abs| rel(abs) }
108
+ end
109
+
110
+ private def coverage_timestamp
111
+ @cov_timestamp || 0
112
+ end
113
+
114
+ private def resultset_path
115
+ @resultset_path ||= CovUtil.find_resultset(@root, resultset: @resultset)
116
+ rescue
117
+ nil
118
+ end
119
+
120
+ private def safe_count_lines(path)
121
+ return 0 unless File.file?(path)
122
+
123
+ File.foreach(path).count
124
+ rescue
125
+ 0
126
+ end
127
+
128
+ private def missing_trailing_newline?(path)
129
+ return false unless File.file?(path)
130
+
131
+ File.open(path, 'rb') do |f|
132
+ size = f.size
133
+ return false if size.zero?
134
+
135
+ f.seek(-1, IO::SEEK_END)
136
+ f.getbyte != 0x0A
137
+ end
138
+ rescue
139
+ false
140
+ end
141
+
142
+ private def rel(path)
143
+ # Handle relative vs absolute path mismatches that cause ArgumentError
144
+ Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
145
+ rescue ArgumentError
146
+ # Path is outside the project root or has a different prefix type, fall back to absolute path
147
+ path.to_s
148
+ end
149
+
150
+ # Centralized computation of staleness-related details for a single file.
151
+ # Returns a Hash with keys:
152
+ # :exists, :file_mtime, :coverage_timestamp, :cov_len, :src_len, :newer, :len_mismatch
153
+ private def compute_file_staleness_details(file_abs, coverage_lines)
154
+ coverage_ts = coverage_timestamp
155
+
156
+ exists = File.file?(file_abs)
157
+ file_mtime = exists ? File.mtime(file_abs) : nil
158
+
159
+ cov_len = coverage_lines.respond_to?(:length) ? coverage_lines.length : 0
160
+ src_len = exists ? safe_count_lines(file_abs) : 0
161
+
162
+ # Adjust source line count to handle edge cases with missing trailing newlines
163
+ adjusted_src_len = adjust_line_count_for_missing_newline(
164
+ file_abs: file_abs,
165
+ exists: exists,
166
+ cov_len: cov_len,
167
+ src_len: src_len
168
+ )
169
+
170
+ # Check if the source file has been modified since coverage was generated
171
+ len_mismatch = length_mismatch?(cov_len, adjusted_src_len)
172
+ newer = check_file_newer_than_coverage(file_mtime, coverage_ts, len_mismatch)
173
+
174
+ {
175
+ exists: exists,
176
+ file_mtime: file_mtime,
177
+ coverage_timestamp: coverage_ts,
178
+ cov_len: cov_len,
179
+ src_len: src_len,
180
+ newer: newer,
181
+ len_mismatch: len_mismatch
182
+ }
183
+ end
184
+
185
+ # Adjusts the source line count to account for files missing trailing newlines.
186
+ #
187
+ # Why this edge case exists:
188
+ # - File.foreach counts lines by separator (typically \n)
189
+ # - For a file with no trailing newline, File.foreach still counts all lines correctly
190
+ # - However, some editors or file operations may report one extra line when checking
191
+ # if the file doesn't end with a newline
192
+ # - SimpleCov's coverage array length matches the logical line count (excluding trailing newline)
193
+ # - If src_len is exactly one more than cov_len AND the file is missing a trailing newline,
194
+ # we adjust src_len down by 1 to match SimpleCov's convention
195
+ #
196
+ # Example: A file with "line1\nline2\nline3" (no final \n)
197
+ # - File.foreach counts: 3 lines
198
+ # - SimpleCov coverage array length: 3
199
+ # - No adjustment needed
200
+ #
201
+ # However, in certain edge cases where the file system or parsing reports an extra line:
202
+ # - Reported line count: 4
203
+ # - SimpleCov coverage array length: 3
204
+ # - Missing trailing newline: true
205
+ # - Adjustment: 4 - 1 = 3 (now matches)
206
+ private def adjust_line_count_for_missing_newline(file_abs:, exists:, cov_len:, src_len:)
207
+ # Only adjust if:
208
+ # 1. File exists (can't check newlines for missing files)
209
+ # 2. Coverage data is present (cov_len > 0)
210
+ # 3. Source has exactly one more line than coverage
211
+ # 4. File is missing a trailing newline
212
+ needs_adjusting =
213
+ exists && cov_len.positive? && src_len == cov_len + 1 && missing_trailing_newline?(file_abs)
214
+ needs_adjusting ? src_len - 1 : src_len
215
+ end
216
+
217
+ # Checks if the source line count differs from the coverage line count.
218
+ #
219
+ # Why this check exists:
220
+ # - When a file is modified after coverage is generated, the line count often changes
221
+ # - A mismatch indicates the coverage data is stale and no longer represents the current file
222
+ # - We only flag as mismatch when coverage data exists (cov_len > 0)
223
+ #
224
+ # Note: Empty coverage (cov_len == 0) is not considered a mismatch, as it may represent
225
+ # files that were never executed or files that are legitimately empty.
226
+ private def length_mismatch?(cov_len, adjusted_src_len)
227
+ cov_len.positive? && adjusted_src_len != cov_len
228
+ end
229
+
230
+ # Determines if a file has been modified more recently than the coverage timestamp.
231
+ #
232
+ # Why this check exists:
233
+ # - Files modified after coverage generation may have behavioral changes not captured
234
+ # - However, if there's already a length mismatch, we prioritize that as the staleness indicator
235
+ # - This prevents double-flagging: if lines changed, the file is already stale (length mismatch)
236
+ #
237
+ # The logic: newer &&= !len_mismatch means:
238
+ # - If len_mismatch is true, set newer to false (length mismatch takes precedence)
239
+ # - This way, staleness is categorized as either 'T' (time-based) OR 'L' (length-based), not both
240
+ private def check_file_newer_than_coverage(file_mtime, coverage_ts, len_mismatch)
241
+ newer = !!(file_mtime && file_mtime.to_i > coverage_ts.to_i)
242
+ # If there's a length mismatch, don't also flag as "newer" - the mismatch is more specific
243
+ newer &&= !len_mismatch
244
+ newer
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CovLoupe
4
+ # General-purpose table formatter with box-drawing characters
5
+ # Used by commands to create consistent formatted output
6
+ class TableFormatter
7
+ # Format data as a table with box-drawing characters
8
+ # @param headers [Array<String>] Column headers
9
+ # @param rows [Array<Array>] Data rows (each row is an array of cell values)
10
+ # @param alignments [Array<Symbol>] Column alignments (:left, :right, :center)
11
+ # @return [String] Formatted table
12
+ def self.format(headers:, rows:, alignments: nil)
13
+ return 'No data to display' if rows.empty?
14
+
15
+ alignments ||= [:left] * headers.size
16
+ all_rows = [headers] + rows.map { |row| row.map(&:to_s) }
17
+
18
+ # Calculate column widths
19
+ widths = headers.size.times.map do |col|
20
+ all_rows.map { |row| row[col].to_s.length }.max
21
+ end
22
+
23
+ lines = []
24
+ lines << border_line(widths, '┌', '┬', '┐')
25
+ lines << data_row(headers, widths, alignments)
26
+ lines << border_line(widths, '├', '┼', '┤')
27
+ rows.each { |row| lines << data_row(row, widths, alignments) }
28
+ lines << border_line(widths, '└', '┴', '┘')
29
+
30
+ lines.join("\n")
31
+ end
32
+
33
+ # Format a single key-value table (vertical layout)
34
+ # @param data [Hash] Key-value pairs
35
+ # @return [String] Formatted table
36
+ def self.format_vertical(data)
37
+ rows = data.map { |k, v| [k.to_s, v.to_s] }
38
+ format(headers: ['Key', 'Value'], rows: rows, alignments: [:left, :left])
39
+ end
40
+
41
+ private_class_method def self.border_line(widths, left, mid, right)
42
+ segments = widths.map { |w| '─' * (w + 2) }
43
+ left + segments.join(mid) + right
44
+ end
45
+
46
+ private_class_method def self.data_row(cells, widths, alignments)
47
+ formatted = cells.each_with_index.map do |cell, i|
48
+ align_cell(cell.to_s, widths[i], alignments[i])
49
+ end
50
+ "│ #{formatted.join(' │ ')} │"
51
+ end
52
+
53
+ private_class_method def self.align_cell(content, width, alignment)
54
+ case alignment
55
+ when :right
56
+ content.rjust(width)
57
+ when :center
58
+ content.center(width)
59
+ else # :left
60
+ content.ljust(width)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../model'
4
+ require_relative '../base_tool'
5
+ require_relative '../presenters/project_coverage_presenter'
6
+
7
+ module CovLoupe
8
+ module Tools
9
+ class AllFilesCoverageTool < BaseTool
10
+ description <<~DESC
11
+ Use this when the user wants coverage percentages for every tracked file in the project.
12
+ Do not use this for single-file stats; prefer coverage.summary or coverage.uncovered_lines for that.
13
+ Inputs: optional project root, alternate .resultset path, sort order, staleness mode, and tracked_globs to alert on new files.
14
+ Output: JSON {"files": [{"file","covered","total","percentage","stale"}, ...], "counts": {"total", "ok", "stale"}} sorted as requested. "stale" is a string ('M', 'T', 'L') or false.
15
+ Examples: "List files with the lowest coverage"; "Show repo coverage sorted descending".
16
+ DESC
17
+ input_schema(**coverage_schema(
18
+ additional_properties: {
19
+ sort_order: {
20
+ type: 'string',
21
+ description: 'Sort order for coverage percentages.' \
22
+ "'ascending' highlights the riskiest files first.",
23
+ default: 'ascending',
24
+ enum: ['ascending', 'descending']
25
+ },
26
+ tracked_globs: TRACKED_GLOBS_PROPERTY
27
+ }
28
+ ))
29
+ class << self
30
+ def call(root: '.', resultset: nil, sort_order: 'ascending', staleness: :off,
31
+ tracked_globs: nil, error_mode: 'log', server_context:)
32
+ with_error_handling('AllFilesCoverageTool', error_mode: error_mode) do
33
+ # Convert string inputs from MCP to symbols for internal use
34
+ sort_order_sym = sort_order.to_sym
35
+ staleness_sym = staleness.to_sym
36
+
37
+ model = CoverageModel.new(root: root, resultset: resultset, staleness: staleness_sym,
38
+ tracked_globs: tracked_globs)
39
+ presenter = Presenters::ProjectCoveragePresenter.new(
40
+ model: model,
41
+ sort_order: sort_order_sym,
42
+ check_stale: (staleness_sym == :error),
43
+ tracked_globs: tracked_globs
44
+ )
45
+ respond_json(presenter.relativized_payload, name: 'all_files_coverage.json')
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_tool'
4
+ require_relative '../model'
5
+ require_relative '../presenters/coverage_detailed_presenter'
6
+
7
+ module CovLoupe
8
+ module Tools
9
+ class CoverageDetailedTool < BaseTool
10
+ description <<~DESC
11
+ Use this when the user needs per-line coverage data for a single file.
12
+ Do not use this for high-level counts; coverage.summary is cheaper for aggregate numbers.
13
+ Inputs: file path (required) plus optional root/resultset/staleness mode inherited from BaseTool.
14
+ Output: JSON object with "file", "lines" => [{"line": 12, "hits": 0, "covered": false}], plus "summary" with totals and "stale" status.
15
+ Example: "Show detailed coverage for lib/cov_loupe/model.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('CoverageDetailedTool', error_mode: error_mode) do
22
+ model = CoverageModel.new(
23
+ root: root,
24
+ resultset: resultset,
25
+ staleness: staleness.to_sym
26
+ )
27
+ presenter = Presenters::CoverageDetailedPresenter.new(model: model, path: path)
28
+ respond_json(presenter.relativized_payload, name: 'coverage_detailed.json',
29
+ pretty: true)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ 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_raw_presenter'
6
+
7
+ module CovLoupe
8
+ module Tools
9
+ class CoverageRawTool < BaseTool
10
+ description <<~DESC
11
+ Use this when you need the raw SimpleCov `lines` array for a file exactly as stored on disk.
12
+ Do not use this for human-friendly explanations; choose coverage.detailed or coverage.summary instead.
13
+ Inputs: file path (required) plus optional root/resultset/staleness mode inherited from BaseTool.
14
+ Output: JSON object with "file" and "lines" (array of integers/nulls) mirroring SimpleCov's native structure, plus "stale" status.
15
+ Example: "Fetch the raw coverage array for spec/support/foo_helper.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('CoverageRawTool', error_mode: error_mode) do
22
+ model = CoverageModel.new(
23
+ root: root,
24
+ resultset: resultset,
25
+ staleness: staleness.to_sym
26
+ )
27
+ presenter = Presenters::CoverageRawPresenter.new(model: model, path: path)
28
+ respond_json(presenter.relativized_payload, name: 'coverage_raw.json', pretty: true)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ 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_summary_presenter'
6
+
7
+ module CovLoupe
8
+ module Tools
9
+ class CoverageSummaryTool < BaseTool
10
+ description <<~DESC
11
+ Use this when the user asks for the covered/total line counts and percentage for a specific file.
12
+ Do not use this for multi-file reports; coverage.all_files or coverage.table handle those.
13
+ Inputs: file path (required) plus optional root/resultset/staleness mode inherited from BaseTool.
14
+ Output: JSON object {"file": String, "summary": {"covered": Integer, "total": Integer, "percentage": Float}, "stale": String|False}.
15
+ Examples: "What is the coverage for lib/cov_loupe/tools/all_files_coverage_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('CoverageSummaryTool', error_mode: error_mode) do
22
+ model = CoverageModel.new(
23
+ root: root,
24
+ resultset: resultset,
25
+ staleness: staleness.to_sym
26
+ )
27
+ presenter = Presenters::CoverageSummaryPresenter.new(model: model, path: path)
28
+ respond_json(presenter.relativized_payload, name: 'coverage_summary.json', pretty: true)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end