Class: CovLoupe::CoverageModel
- Inherits:
-
Object
- Object
- CovLoupe::CoverageModel
- Defined in:
- lib/cov_loupe/model/model.rb
Overview
Core domain model for querying SimpleCov coverage data.
Provides file-level queries (raw_for, summary_for, uncovered_for, detailed_for), project-level queries (list, project_totals), and table formatting (format_table).
Construction eagerly validates that the resultset exists and loads initial data via ModelDataCache. Subsequent data accesses check the cache on every call and auto-reload if the resultset file has changed (based on mtime + MD5 digest).
The model delegates to:
- CoverageCalculator for line counting and aggregation
- StalenessChecker for time-based and line-count staleness detection
- CoverageLineResolver for looking up files in the coverage map
- PathRelativizer for converting absolute paths to project-relative form
Thread safety: CoverageModel instances are NOT thread-safe. The shared ModelDataCache is thread-safe, but per-instance state like @skipped_rows is mutable and unsynchronized.
Constant Summary collapse
- RELATIVIZER_SCALAR_KEYS =
%w[file file_path].freeze
- RELATIVIZER_ARRAY_KEYS =
%w[ newer_files deleted_files missing_tracked_files skipped_files length_mismatch_files unreadable_files ].freeze
- DEFAULT_SORT_ORDER =
:descending
Instance Attribute Summary collapse
-
#relativizer ⇒ Object
readonly
Returns the value of attribute relativizer.
-
#skipped_rows ⇒ Object
readonly
Returns the value of attribute skipped_rows.
-
#volume_case_sensitive ⇒ Object
readonly
Returns the value of attribute volume_case_sensitive.
Instance Method Summary collapse
-
#detailed_for(path, raise_on_stale: @default_raise_on_stale) ⇒ Object
Returns { 'file' => <absolute_path>, 'lines' => ['line'=>,'hits'=>,'covered'=>,...], 'summary' => ... }.
-
#format_table(rows = nil, sort_order: DEFAULT_SORT_ORDER, raise_on_stale: @default_raise_on_stale, tracked_globs: @default_tracked_globs, output_chars: :default) ⇒ String
Returns formatted table string for all files coverage data Delegates to CoverageTableFormatter for presentation logic.
-
#initialize(root: '.', resultset: nil, raise_on_stale: false, tracked_globs: [], logger: nil) ⇒ CoverageModel
constructor
Create a CoverageModel.
-
#list(sort_order: DEFAULT_SORT_ORDER, raise_on_stale: @default_raise_on_stale, tracked_globs: @default_tracked_globs) ⇒ Hash
Returns a list of coverage data for all tracked files.
-
#project_totals(tracked_globs: @default_tracked_globs, raise_on_stale: @default_raise_on_stale) ⇒ Object
Aggregates per-file coverage into project-wide totals.
-
#raw_for(path, raise_on_stale: @default_raise_on_stale) ⇒ Object
Returns { 'file' => <absolute_path>, 'lines' => [hits|nil,...] }.
-
#refresh_data ⇒ Object
Clears the resolved resultset path to allow re-resolution.
- #relativize(payload) ⇒ Object
- #staleness_for(path) ⇒ Object
-
#summary_for(path, raise_on_stale: @default_raise_on_stale) ⇒ Object
Returns { 'file' => <absolute_path>, 'summary' => 'total'=>, 'percentage'=> }.
-
#uncovered_for(path, raise_on_stale: @default_raise_on_stale) ⇒ Object
Returns { 'file' => <absolute_path>, 'uncovered' => [line,...], 'summary' => ... }.
Constructor Details
#initialize(root: '.', resultset: nil, raise_on_stale: false, tracked_globs: [], logger: nil) ⇒ CoverageModel
Create a CoverageModel
Params:
- root: project root directory (default '.')
- resultset: path or directory to .resultset.json
- raise_on_stale: boolean (default false). When true, raises stale errors if sources are newer than coverage or line counts mismatch.
- tracked_globs: array of glob patterns (default []). Used for filtering and tracking.
- logger: logger instance (defaults to CovLoupe.logger)
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
# File 'lib/cov_loupe/model/model.rb', line 63 def initialize(root: '.', resultset: nil, raise_on_stale: false, tracked_globs: [], logger: nil) @root = File.(root || '.') @resultset_arg = resultset @default_tracked_globs = tracked_globs @skipped_rows = [] @logger = logger || CovLoupe.logger @relativizer = PathRelativizer.new( root: @root, scalar_keys: RELATIVIZER_SCALAR_KEYS, array_keys: RELATIVIZER_ARRAY_KEYS ) @default_raise_on_stale = raise_on_stale @resolved_resultset_path = nil # Resolved on first fetch # Eagerly validate resultset exists and load initial data # This matches original behavior and surfaces errors immediately begin data = fetch_data @resultset_path = data.resultset_path rescue CovLoupe::Error raise # Re-raise our own errors as-is rescue => e raise ErrorHandler.new.convert_standard_error(e, context: :coverage_loading) end # Compute volume case sensitivity based on this model's root directory # This is not cached because different models may use the same resultset # with different root directories on different volumes @volume_case_sensitive = PathUtils.volume_case_sensitive?(@root) end |
Instance Attribute Details
#relativizer ⇒ Object (readonly)
Returns the value of attribute relativizer.
52 53 54 |
# File 'lib/cov_loupe/model/model.rb', line 52 def relativizer @relativizer end |
#skipped_rows ⇒ Object (readonly)
Returns the value of attribute skipped_rows.
52 53 54 |
# File 'lib/cov_loupe/model/model.rb', line 52 def skipped_rows @skipped_rows end |
#volume_case_sensitive ⇒ Object (readonly)
Returns the value of attribute volume_case_sensitive.
52 53 54 |
# File 'lib/cov_loupe/model/model.rb', line 52 def volume_case_sensitive @volume_case_sensitive end |
Instance Method Details
#detailed_for(path, raise_on_stale: @default_raise_on_stale) ⇒ Object
Returns { 'file' => <absolute_path>, 'lines' => ['line'=>,'hits'=>,'covered'=>,...], 'summary' => ... }
122 123 124 125 126 127 128 129 |
# File 'lib/cov_loupe/model/model.rb', line 122 def detailed_for(path, raise_on_stale: @default_raise_on_stale) file_abs, coverage_lines = coverage_data_for(path, raise_on_stale: raise_on_stale) { 'file' => file_abs, 'lines' => CoverageCalculator.detailed(coverage_lines), 'summary' => CoverageCalculator.summary(coverage_lines), } end |
#format_table(rows = nil, sort_order: DEFAULT_SORT_ORDER, raise_on_stale: @default_raise_on_stale, tracked_globs: @default_tracked_globs, output_chars: :default) ⇒ String
Returns formatted table string for all files coverage data Delegates to CoverageTableFormatter for presentation logic
246 247 248 249 250 251 252 253 |
# File 'lib/cov_loupe/model/model.rb', line 246 def format_table(rows = nil, sort_order: DEFAULT_SORT_ORDER, raise_on_stale: @default_raise_on_stale, tracked_globs: @default_tracked_globs, output_chars: :default) rows = prepare_rows(rows, sort_order: sort_order, raise_on_stale: raise_on_stale, tracked_globs: tracked_globs) CoverageTableFormatter.format(rows, output_chars: output_chars) end |
#list(sort_order: DEFAULT_SORT_ORDER, raise_on_stale: @default_raise_on_stale, tracked_globs: @default_tracked_globs) ⇒ Hash
Complexity: O(n log n) where n is the number of tracked files due to sorting. File I/O for staleness checks adds O(m) where m is the number of tracked source files.
Thread-safety: Not thread-safe. This method relies on shared mutable state (@skipped_rows, internal coverage_map) and should not be called concurrently from multiple threads on the same CoverageModel instance.
Returns a list of coverage data for all tracked files.
151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 |
# File 'lib/cov_loupe/model/model.rb', line 151 def list(sort_order: DEFAULT_SORT_ORDER, raise_on_stale: @default_raise_on_stale, tracked_globs: @default_tracked_globs) @skipped_rows = [] # Build rows in lenient mode to collect all data even if some files have errors # This ensures staleness checking can examine all files, not just the ones before # the first error. We'll re-raise any errors after staleness checking if needed. rows, coverage_lines_by_path = build_list_rows( tracked_globs: tracked_globs, raise_on_stale: false # Always use lenient mode for row building ) project_staleness_details = project_staleness_report( tracked_globs: tracked_globs, raise_on_stale: raise_on_stale, # Honor raise_on_stale for staleness checks coverage_lines_by_path: coverage_lines_by_path ) # If raise_on_stale is true and there were any skipped files with errors, # raise the first error encountered after staleness checking is complete if raise_on_stale && @skipped_rows.any? first_error = @skipped_rows.first error_class = Object.const_get(first_error['error_class']) raise error_class, first_error['error'] end file_statuses = project_staleness_details[:file_statuses] || {} length_mismatch_files = Array(project_staleness_details[:length_mismatch_files]).uniq unreadable_files = Array(project_staleness_details[:unreadable_files]).uniq rows.each do |row| row['stale'] = file_statuses.fetch(row['file'], 'ok') end { 'files' => sort_rows(rows, sort_order: sort_order), 'skipped_files' => filter_rows_by_globs(@skipped_rows, tracked_globs), 'missing_tracked_files' => project_staleness_details[:missing_files], 'newer_files' => project_staleness_details[:newer_files], 'deleted_files' => project_staleness_details[:deleted_files], 'length_mismatch_files' => length_mismatch_files, 'unreadable_files' => unreadable_files, 'timestamp_status' => project_staleness_details[:timestamp_status], } end |
#project_totals(tracked_globs: @default_tracked_globs, raise_on_stale: @default_raise_on_stale) ⇒ Object
Aggregates per-file coverage into project-wide totals. Partitions files by staleness: stale files are excluded from line counts but still counted in the file breakdown. When tracked_globs are provided, also reports files without coverage data (missing from coverage, skipped, unreadable).
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 |
# File 'lib/cov_loupe/model/model.rb', line 199 def project_totals( tracked_globs: @default_tracked_globs, raise_on_stale: @default_raise_on_stale ) list_result = list(sort_order: :ascending, raise_on_stale: raise_on_stale, tracked_globs: tracked_globs) rows = list_result['files'] included_rows = rows.reject { |row| StaleStatus.stale?(row['stale']) } line_totals = line_totals_from_rows(included_rows).merge( 'included_files' => included_rows.length, 'excluded_files' => rows.length - included_rows.length ) tracking = tracking_payload(tracked_globs) with_coverage = with_coverage_payload(rows) without_coverage = without_coverage_payload(list_result, tracking['enabled']) files = files_payload(with_coverage, without_coverage) { 'lines' => line_totals, 'tracking' => tracking, 'files' => files, 'timestamp_status' => list_result['timestamp_status'], } end |
#raw_for(path, raise_on_stale: @default_raise_on_stale) ⇒ Object
Returns { 'file' => <absolute_path>, 'lines' => [hits|nil,...] }
96 97 98 99 |
# File 'lib/cov_loupe/model/model.rb', line 96 def raw_for(path, raise_on_stale: @default_raise_on_stale) file_abs, coverage_lines = coverage_data_for(path, raise_on_stale: raise_on_stale) { 'file' => file_abs, 'lines' => coverage_lines } end |
#refresh_data ⇒ Object
Clears the resolved resultset path to allow re-resolution. ModelDataCache automatically handles resultset file changes on each access, so explicit refresh is rarely needed. This method is primarily for testing.
283 284 285 286 |
# File 'lib/cov_loupe/model/model.rb', line 283 def refresh_data @resolved_resultset_path = nil self end |
#relativize(payload) ⇒ Object
101 102 103 |
# File 'lib/cov_loupe/model/model.rb', line 101 def relativize(payload) relativizer.relativize(payload) end |
#staleness_for(path) ⇒ Object
226 227 228 229 230 231 232 233 234 235 |
# File 'lib/cov_loupe/model/model.rb', line 226 def staleness_for(path) file_abs = File.(path, @root) coverage_lines = Resolvers::ResolverHelpers.lookup_lines(coverage_map, file_abs, root: @root, volume_case_sensitive: volume_case_sensitive) build_staleness_checker(raise_on_stale: false, tracked_globs: nil) .file_staleness_status(file_abs, coverage_lines) rescue => e @logger.safe_log("Failed to check staleness for #{path}: #{e.}") 'error' end |
#summary_for(path, raise_on_stale: @default_raise_on_stale) ⇒ Object
Returns { 'file' => <absolute_path>, 'summary' => 'total'=>, 'percentage'=> }
106 107 108 109 |
# File 'lib/cov_loupe/model/model.rb', line 106 def summary_for(path, raise_on_stale: @default_raise_on_stale) file_abs, coverage_lines = coverage_data_for(path, raise_on_stale: raise_on_stale) { 'file' => file_abs, 'summary' => CoverageCalculator.summary(coverage_lines) } end |
#uncovered_for(path, raise_on_stale: @default_raise_on_stale) ⇒ Object
Returns { 'file' => <absolute_path>, 'uncovered' => [line,...], 'summary' => ... }
112 113 114 115 116 117 118 119 |
# File 'lib/cov_loupe/model/model.rb', line 112 def uncovered_for(path, raise_on_stale: @default_raise_on_stale) file_abs, coverage_lines = coverage_data_for(path, raise_on_stale: raise_on_stale) { 'file' => file_abs, 'uncovered' => CoverageCalculator.uncovered(coverage_lines), 'summary' => CoverageCalculator.summary(coverage_lines), } end |