Module: Moult::Health

Defined in:
lib/moult/health.rb,
lib/moult/health/score.rb

Overview

Orchestrates the health score: it runs each existing analysis, extracts the numeric signals each one exposes, and composes them through the pure Score model into one auditable HealthReport. There is no external tool here — the "adapter" is this composition of Moult's own reports.

This is the only layer that does IO and knows how the signals are sourced; Score stays a pure function of the extracted numbers so it can be pinned in isolation. Every analysis is run inside its own rescue: a failure degrades that one component to present: false with a diagnostic, never crashing the whole health run.

Defined Under Namespace

Modules: Score Classes: Run

Constant Summary collapse

KNOWN_COMPONENTS =

Fixed component order, so output is stable and every slot is accounted for (present, skipped, or errored).

%w[complexity dead_code duplication coverage boundaries].freeze
SYMBOLS_PER_FILE =

Cap on join keys serialized per file, so the roll-up cannot balloon on a large file; the true total is recorded alongside.

20

Class Method Summary collapse

Class Method Details

.boundaries_by_path(report) ⇒ Object

path => count: from boundary-violation occurrences in that file. Boundary occurrences carry a null symbol_id (file-keyed), so they contribute to the per-file roll-up at PATH granularity only — never to file_symbol_ids.



289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/moult/health.rb', line 289

def boundaries_by_path(report)
  acc = Hash.new { |h, k| h[k] = {weighted: 0.0, count: 0} }
  report.findings.each do |finding|
    weight = boundary_weight(finding)
    finding.occurrences.each do |occ|
      acc[occ.path][:weighted] += weight
      acc[occ.path][:count] += 1
    end
  end
  acc.default_proc = nil # so later missing-key reads return nil instead of mutating
  acc
end

.boundaries_input(report, file_count) ⇒ Object



137
138
139
140
141
142
143
# File 'lib/moult/health.rb', line 137

def boundaries_input(report, file_count)
  Score::BoundariesInput.new(
    file_count: file_count,
    weighted_violations: report.findings.sum { |f| boundary_weight(f) * f.occurrences.size },
    violation_count: report.findings.sum { |f| f.occurrences.size }
  )
end

.boundaries_present?(run) ⇒ Boolean

A boundaries run contributes only when it ran AND the project is packwerk- configured; an unconfigured repo yields a successful-but-empty report that must be SKIPPED, not scored as vacuously healthy.

Returns:

  • (Boolean)


133
134
135
# File 'lib/moult/health.rb', line 133

def boundaries_present?(run)
  run.ok? && run.value.configured
end

.boundary_weight(finding) ⇒ Object



145
146
147
# File 'lib/moult/health.rb', line 145

def boundary_weight(finding)
  Boundaries::Severity::SEVERITY_WEIGHT.fetch(finding.severity, Boundaries::Severity::SEVERITY_WEIGHT.fetch("low"))
end

.build_report(root:, files:, index:, rails:, coverage: nil, since: nil, git_ref: nil, generated_at: nil, churn_window: nil, churn_since: nil) ⇒ HealthReport

Parameters:

  • root (String)

    absolute analysis root

  • files (Array<String>)

    absolute Ruby file paths to analyse

  • index (Index)

    resolved definition/reference index (drives dead-code + coverage)

  • rails (RailsConventions)

    Rails entrypoint awareness for dead-code

  • coverage (Coverage::Dataset, nil) (defaults to: nil)

    runtime coverage to merge (adds the coverage component)

  • since (String, nil) (defaults to: nil)

    churn window start for the complexity component

Returns:



39
40
41
42
43
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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/moult/health.rb', line 39

def build_report(root:, files:, index:, rails:, coverage: nil, since: nil,
  git_ref: nil, generated_at: nil, churn_window: nil, churn_since: nil)
  churn = Churn.collect(root: root, since: since || Churn::DEFAULT_SINCE)

  runs = {
    "complexity" => run { Scoring.build_report(root: root, files: files, churn: churn) },
    "dead_code" => run { DeadCode.build_report(root: root, files: files, index: index, rails: rails, coverage: coverage) },
    "duplication" => run { Duplication.build_report(root: root, files: files) },
    "coverage" => run { coverage ? CoverageReport.build(index: index, coverage: coverage, root: root) : nil },
    "boundaries" => run { Boundaries.build_report(root: root) }
  }

  # Derive churn presence from the JOINED hotspots, not the raw churn hash: a
  # repo-relative churn map run against a subdir root won't join to the scored
  # files, so the honest signal is "did any scored file actually carry churn".
  churn_present = runs["complexity"].ok? &&
    runs["complexity"].value.hotspots.any? { |h| h.churn.to_i.positive? }

  inputs = Score::Inputs.new(
    complexity: runs["complexity"].ok? ? complexity_input(runs["complexity"].value, churn_present) : nil,
    dead_code: runs["dead_code"].ok? ? dead_code_input(runs["dead_code"].value, index) : nil,
    duplication: runs["duplication"].ok? ? duplication_input(runs["duplication"].value, files.size) : nil,
    coverage: runs["coverage"].ok? ? coverage_input(runs["coverage"].value) : nil,
    # Absent (skipped) unless the project is actually packwerk-configured: an
    # unconfigured repo has no boundary signal and must not read as healthy 1.0.
    boundaries: boundaries_present?(runs["boundaries"]) ? boundaries_input(runs["boundaries"].value, files.size) : nil
  )

  composite = Score.assess(inputs)
  components = component_views(composite, runs, coverage_requested: !coverage.nil?)
  files_view = file_rollup(runs, index, churn_present)

  HealthReport.new(
    root: root,
    score: composite.score,
    grade: composite.grade,
    components: components,
    files: files_view,
    git_ref: git_ref,
    generated_at: generated_at,
    coverage_source: coverage&.source,
    churn_window: churn_window,
    churn_since: churn_since
  )
end

.clones_by_path(report) ⇒ Object

path => sets:, symbol_ids: from clone occurrences in that file.



272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/moult/health.rb', line 272

def clones_by_path(report)
  acc = Hash.new { |h, k| h[k] = {weighted_mass: 0.0, sets: 0, symbol_ids: []} }
  report.findings.each do |finding|
    finding.occurrences.each do |occ|
      bucket = acc[occ.path]
      bucket[:weighted_mass] += finding.confidence * finding.mass
      bucket[:sets] += 1
      bucket[:symbol_ids] << occ.symbol_id if occ.symbol_id
    end
  end
  acc.default_proc = nil # so later missing-key reads return nil instead of mutating
  acc
end

.complexity_input(report, churn_present) ⇒ Object

---- signal extraction (heavy report -> pure numeric input) ---------------



95
96
97
98
99
100
101
102
103
# File 'lib/moult/health.rb', line 95

def complexity_input(report, churn_present)
  hs = report.hotspots
  Score::ComplexityInput.new(
    file_count: hs.size,
    total_complexity: hs.sum(&:complexity),
    total_score: hs.sum(&:score),
    churn_present: churn_present
  )
end

.component_views(composite, runs, coverage_requested:) ⇒ Object

---- component views (every slot, present or not) -------------------------



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
# File 'lib/moult/health.rb', line 151

def component_views(composite, runs, coverage_requested:)
  present = composite.components.to_h { |c| [c.name, c] }
  present_names = composite.components.map(&:name)

  KNOWN_COMPONENTS.map do |name|
    component = present[name]
    if component
      HealthReport::ComponentView.new(
        name: name,
        category: component.category,
        present: true,
        score: component.score,
        weight: Score::WEIGHTS.fetch(name),
        normalized_weight: Score.normalized_weight(name, present_names),
        summary: component.stats,
        reasons: component.reasons,
        diagnostic: nil
      )
    else
      HealthReport::ComponentView.new(
        name: name,
        category: nil,
        present: false,
        score: nil,
        weight: Score::WEIGHTS.fetch(name),
        normalized_weight: nil,
        summary: {},
        reasons: [],
        diagnostic: diagnostic_for(name, runs[name], coverage_requested)
      )
    end
  end
end

.coverage_by_path(report) ⇒ Object

path => cold:, cold_ids: from coverage entries (path parsed from symbol_id).



303
304
305
306
307
308
309
310
311
312
313
314
315
316
# File 'lib/moult/health.rb', line 303

def coverage_by_path(report)
  acc = Hash.new { |h, k| h[k] = {hot: 0, cold: 0, cold_ids: []} }
  report.entries.each do |entry|
    path = entry.symbol_id.split(":", 3).first
    case entry.runtime
    when :hot then acc[path][:hot] += 1
    when :cold
      acc[path][:cold] += 1
      acc[path][:cold_ids] << entry.symbol_id
    end
  end
  acc.default_proc = nil # so later missing-key reads return nil instead of mutating
  acc
end

.coverage_input(report) ⇒ Object



125
126
127
128
# File 'lib/moult/health.rb', line 125

def coverage_input(report)
  s = report.summary
  Score::CoverageInput.new(hot: s[:hot], cold: s[:cold])
end

.dead_code_input(report, index) ⇒ Object



105
106
107
108
109
110
111
112
# File 'lib/moult/health.rb', line 105

def dead_code_input(report, index)
  Score::DeadCodeInput.new(
    symbol_count: index.definitions.size,
    confidence_sum: report.findings.sum(&:confidence),
    finding_count: report.findings.size,
    resolved: report.resolved
  )
end

.diagnostic_for(name, run, coverage_requested) ⇒ Object



185
186
187
188
189
190
191
192
193
194
195
# File 'lib/moult/health.rb', line 185

def diagnostic_for(name, run, coverage_requested)
  return run.error if run&.error
  case name
  when "coverage"
    coverage_requested ? "coverage produced no usable signal" : "no --coverage supplied"
  when "boundaries"
    "not a packwerk project (no packwerk.yml)"
  else
    "analysis produced no result"
  end
end

.duplication_input(report, file_count) ⇒ Object



114
115
116
117
118
119
120
121
122
123
# File 'lib/moult/health.rb', line 114

def duplication_input(report, file_count)
  # Only the EXTRA copies are consolidatable mass; confidence-weight so a
  # low-confidence "similar" rhyme barely registers.
  weighted = report.findings.sum { |f| f.confidence * f.mass * (f.occurrences.size - 1) }
  Score::DuplicationInput.new(
    file_count: file_count,
    weighted_dup_mass: weighted,
    set_count: report.findings.size
  )
end

.file_rollup(runs, index, churn_present) ⇒ Object

---- per-file roll-up (the cross-analysis join surface) -------------------



199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/moult/health.rb', line 199

def file_rollup(runs, index, churn_present)
  hotspots = runs["complexity"].ok? ? runs["complexity"].value.hotspots.to_h { |h| [h.path, h] } : {}
  dead = runs["dead_code"].ok? ? runs["dead_code"].value.findings.group_by(&:path) : {}
  dup = runs["duplication"].ok? ? clones_by_path(runs["duplication"].value) : {}
  cov = runs["coverage"].ok? ? coverage_by_path(runs["coverage"].value) : {}
  bnd = boundaries_present?(runs["boundaries"]) ? boundaries_by_path(runs["boundaries"].value) : {}
  symbols_per_file = index.definitions.group_by(&:path).transform_values(&:size)

  paths = Set.new
  paths.merge(hotspots.keys)
  paths.merge(dead.keys)
  paths.merge(dup.keys)
  paths.merge(cov.select { |_, c| c[:cold].positive? }.keys)
  paths.merge(bnd.keys)

  views = paths.map do |path|
    file_view(path, hotspots[path], dead[path], dup[path], cov[path], bnd[path],
      symbols_per_file[path], churn_present)
  end
  # Least-healthy first, path as a deterministic tie-break.
  views.sort_by { |v| [v.score, v.path] }
end

.file_symbol_ids(hotspot, dead_findings, clone, coverage) ⇒ Object

Contributing join keys for a file, dead-finding / clone / coverage / hotspot in that order, de-duplicated.



258
259
260
261
262
263
264
265
# File 'lib/moult/health.rb', line 258

def file_symbol_ids(hotspot, dead_findings, clone, coverage)
  ids = []
  ids.concat(dead_findings.map(&:symbol_id)) if dead_findings
  ids.concat(clone[:symbol_ids]) if clone
  ids.concat(coverage[:cold_ids]) if coverage
  ids.concat(hotspot.methods.map(&:symbol_id)) if hotspot
  ids.compact.uniq
end

.file_view(path, hotspot, dead_findings, clone, coverage, boundaries, symbol_count, churn_present) ⇒ Object



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'lib/moult/health.rb', line 222

def file_view(path, hotspot, dead_findings, clone, coverage, boundaries, symbol_count, churn_present)
  inputs = Score::Inputs.new(
    complexity: hotspot && Score::ComplexityInput.new(
      file_count: 1, total_complexity: hotspot.complexity,
      total_score: hotspot.score, churn_present: churn_present
    ),
    dead_code: dead_findings && Score::DeadCodeInput.new(
      symbol_count: [symbol_count.to_i, dead_findings.size].max,
      confidence_sum: dead_findings.sum(&:confidence),
      finding_count: dead_findings.size, resolved: true
    ),
    duplication: clone && Score::DuplicationInput.new(
      file_count: 1, weighted_dup_mass: clone[:weighted_mass], set_count: clone[:sets]
    ),
    coverage: coverage && tracked?(coverage) && Score::CoverageInput.new(
      hot: coverage[:hot], cold: coverage[:cold]
    ),
    boundaries: boundaries && Score::BoundariesInput.new(
      file_count: 1, weighted_violations: boundaries[:weighted], violation_count: boundaries[:count]
    )
  )

  composite = Score.assess(inputs)
  ids = file_symbol_ids(hotspot, dead_findings, clone, coverage)
  HealthReport::FileView.new(
    path: path,
    score: composite.score,
    grade: composite.grade,
    components: composite.components.to_h { |c| [c.name, c.score] },
    symbol_ids: ids.first(SYMBOLS_PER_FILE),
    symbol_count: ids.size
  )
end

.runObject

Run one analysis in isolation: success carries the report, any failure carries the message so the component degrades rather than the whole run.



87
88
89
90
91
# File 'lib/moult/health.rb', line 87

def run
  Run.new(value: yield, error: nil)
rescue => e
  Run.new(value: nil, error: e.message)
end

.tracked?(coverage) ⇒ Boolean

Returns:

  • (Boolean)


267
268
269
# File 'lib/moult/health.rb', line 267

def tracked?(coverage)
  (coverage[:hot] + coverage[:cold]).positive?
end