Class: CovLoupe::StalenessChecker

Inherits:
Object
  • Object
show all
Defined in:
lib/cov_loupe/staleness/staleness_checker.rb

Overview

Lightweight service object to check staleness of coverage vs. sources.

Staleness detection operates at two levels:

- File level: checks if a single source file is newer than the coverage timestamp
or has a different line count than the coverage array
- Project level: scans all tracked files for newer/missing/deleted/mismatched files

Modes:

- :off   → compute staleness but never raise (used for status reporting)
- :error → raise CoverageDataStaleError or CoverageDataProjectStaleError on issues

Staleness categories (mutually exclusive per file):

- 'ok'             → file is fresh and line counts match
- 'missing'        → source file deleted since coverage was recorded
- 'newer'          → source mtime exceeds coverage timestamp (only when no length mismatch)
- 'length_mismatch'→ source line count differs from coverage array length
- 'error'          → file could not be read (permissions, I/O)

Constant Summary collapse

MODES =
%i[off error].freeze

Instance Method Summary collapse

Constructor Details

#initialize(root:, resultset:, mode: :off, tracked_globs: nil, timestamp: nil) ⇒ StalenessChecker

Returns a new instance of StalenessChecker.



30
31
32
33
34
35
36
37
# File 'lib/cov_loupe/staleness/staleness_checker.rb', line 30

def initialize(root:, resultset:, mode: :off, tracked_globs: nil, timestamp: nil)
  @root = File.expand_path(root || '.')
  @resultset = resultset
  @mode = (mode || :off).to_sym
  @tracked_globs = tracked_globs
  @cov_timestamp = timestamp
  @resultset_path = nil
end

Instance Method Details

#check_file!(file_abs, coverage_lines) ⇒ Object

Raise CoverageDataStaleError if stale (only in error mode)



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/cov_loupe/staleness/staleness_checker.rb', line 44

def check_file!(file_abs, coverage_lines)
  return if off?

  d = compute_file_staleness_details(file_abs, coverage_lines)

  # Raise FileError if there was a read error
  if d[:read_error]
    raise FileError, "Error reading file: #{rel(file_abs)}"
  end

  # For single-file checks, missing files with recorded coverage count as stale
  # via length mismatch; project-level checks also handle deleted files explicitly.
  if d[:newer] || d[:len_mismatch]
    raise CoverageDataStaleError.new(
      nil,
      nil,
      file_path:      rel(file_abs),
      file_mtime:     d[:file_mtime],
      cov_timestamp:  d[:coverage_timestamp],
      src_len:        d[:src_len],
      cov_len:        d[:cov_len],
      resultset_path: resultset_path
    )
  end
end

#check_project!(coverage_map) ⇒ Object

Compute and return project staleness details (newer, missing, deleted files). If in error mode, raises CoverageDataProjectStaleError when issues are found. Returns a hash { newer_files: [], missing_files: [], deleted_files: [], unreadable_files: [] }

Does not check line counts. Use check_project_with_lines! for full staleness detection.



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/cov_loupe/staleness/staleness_checker.rb', line 92

def check_project!(coverage_map)
  ts = coverage_timestamp
  coverage_files = coverage_map.keys

  newer, deleted, unreadable = compute_newer_and_deleted_files(coverage_files, ts)
  missing = compute_missing_files(coverage_files)

  staleness_details = {
    newer_files:      newer,
    missing_files:    missing,
    deleted_files:    deleted,
    unreadable_files: unreadable,
    timestamp_status: ts.to_i > 0 ? 'ok' : 'missing',
  }

  if @mode == :error && (newer.any? || missing.any? || deleted.any? || unreadable.any?)
    raise CoverageDataProjectStaleError.new(
      nil,
      nil,
      cov_timestamp:    ts,
      newer_files:      newer,
      missing_files:    missing,
      deleted_files:    deleted,
      unreadable_files: unreadable,
      resultset_path:   resultset_path
    )
  end

  staleness_details
end

#check_project_with_lines!(coverage_lines_by_path, coverage_files:) ⇒ Object

Compute and return project staleness details including line-count mismatches. If in error mode, raises CoverageDataProjectStaleError when issues are found. Returns a hash with newer/missing/deleted/mismatched/unreadable files and per-file statuses.

Deduplication: files that are both "newer" AND have a length mismatch are reported only as "length_mismatch" (the more specific diagnosis). This prevents a file from appearing in both the newer_files and length_mismatch_files lists.



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
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
# File 'lib/cov_loupe/staleness/staleness_checker.rb', line 130

def check_project_with_lines!(coverage_lines_by_path, coverage_files:)
  coverage_lines_by_path ||= {}
  ts = coverage_timestamp

  newer, deleted, unreadable = compute_newer_and_deleted_files(coverage_files, ts)
  missing = compute_missing_files(coverage_files)

  file_statuses = {}
  length_mismatch = []

  coverage_lines_by_path.each do |abs_path, coverage_lines|
    details = compute_file_staleness_details(abs_path, coverage_lines)
    status = if details[:read_error]
      'error'
    elsif !details[:exists]
      'missing'
    elsif details[:newer]
      'newer'
    elsif details[:len_mismatch]
      'length_mismatch'
    else
      'ok'
    end
    file_statuses[abs_path] = status
    unreadable << rel(abs_path) if details[:read_error]
    length_mismatch << rel(abs_path) if details[:len_mismatch] && details[:exists]
  end

  # A file can be both newer than the coverage timestamp and otherwise broken (for
  # example unreadable or with a different line count). Keep the more actionable
  # per-file diagnosis and suppress the broader "newer" label to avoid duplicates.
  newer -= length_mismatch
  newer -= unreadable

  staleness_details = {
    newer_files:           newer,
    missing_files:         missing,
    deleted_files:         deleted,
    length_mismatch_files: length_mismatch,
    unreadable_files:      unreadable,
    file_statuses:         file_statuses,
    timestamp_status:      ts.to_i > 0 ? 'ok' : 'missing',
  }

  if @mode == :error && [newer, missing, deleted, length_mismatch, unreadable].any?(&:any?)
    raise CoverageDataProjectStaleError.new(
      nil,
      nil,
      cov_timestamp:         ts,
      newer_files:           newer,
      missing_files:         missing,
      deleted_files:         deleted,
      length_mismatch_files: length_mismatch,
      unreadable_files:      unreadable,
      resultset_path:        resultset_path
    )
  end

  staleness_details
end

#file_staleness_status(file_abs, coverage_lines) ⇒ Object

Compute the staleness status for a specific file relative to coverage. Ignores mode and never raises. Returns a String:

  • 'ok' - file is not stale (fresh)
  • 'missing' - the file is missing/deleted
  • 'newer' - the file mtime is newer than the coverage timestamp
  • 'length_mismatch' - the source line count differs from coverage lines array length
  • 'error' - the file cannot be read due to permission or I/O errors


77
78
79
80
81
82
83
84
85
# File 'lib/cov_loupe/staleness/staleness_checker.rb', line 77

def file_staleness_status(file_abs, coverage_lines)
  d = compute_file_staleness_details(file_abs, coverage_lines)
  return 'error' if d[:read_error]
  return 'missing' unless d[:exists]
  return 'newer' if d[:newer]
  return 'length_mismatch' if d[:len_mismatch]

  'ok'
end

#off?Boolean

Returns:

  • (Boolean)


39
40
41
# File 'lib/cov_loupe/staleness/staleness_checker.rb', line 39

def off?
  @mode == :off
end