Class: CovLoupe::CoverageModel

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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.expand_path(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

#relativizerObject (readonly)

Returns the value of attribute relativizer.



52
53
54
# File 'lib/cov_loupe/model/model.rb', line 52

def relativizer
  @relativizer
end

#skipped_rowsObject (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_sensitiveObject (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

Parameters:

  • rows (Array<Hash>, nil) (defaults to: nil)

    Pre-computed rows, or nil to compute from coverage data

  • sort_order (Symbol) (defaults to: DEFAULT_SORT_ORDER)

    Sort order (:ascending or :descending)

  • raise_on_stale (Boolean) (defaults to: @default_raise_on_stale)

    Whether to raise on stale coverage data

  • tracked_globs (Array<String>, nil) (defaults to: @default_tracked_globs)

    Glob patterns for tracked files

  • output_chars (Symbol) (defaults to: :default)

    Output character mode (:default, :fancy, :ascii)

Returns:

  • (String)

    Formatted table



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

Note:

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.

Note:

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.

Parameters:

  • sort_order (Symbol) (defaults to: DEFAULT_SORT_ORDER)

    :descending (default) or :ascending for sorting by percentage

  • raise_on_stale (Boolean) (defaults to: @default_raise_on_stale)

    When true, raises errors if sources are newer than coverage

  • tracked_globs (Array<String>) (defaults to: @default_tracked_globs)

    Glob patterns to filter tracked files

Returns:

  • (Hash)

    Hash containing:

    • 'files': Array of file coverage data sorted by percentage
    • 'skipped_files': Files that could not be processed
    • 'missing_tracked_files': Tracked files not in coverage data
    • 'newer_files': Coverage data older than source files
    • 'deleted_files': Source files removed since coverage run
    • 'length_mismatch_files': Files with line count mismatches
    • 'unreadable_files': Files that could not be read
    • 'timestamp_status': Overall staleness status


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_dataObject

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.expand_path(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.message}")
  '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