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
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
-
.boundaries_by_path(report) ⇒ Object
path => count: from boundary-violation occurrences in that file.
- .boundaries_input(report, file_count) ⇒ Object
-
.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.
- .boundary_weight(finding) ⇒ Object
- .build_report(root:, files:, index:, rails:, coverage: nil, since: nil, git_ref: nil, generated_at: nil, churn_window: nil, churn_since: nil) ⇒ HealthReport
-
.clones_by_path(report) ⇒ Object
path => sets:, symbol_ids: from clone occurrences in that file.
-
.complexity_input(report, churn_present) ⇒ Object
---- signal extraction (heavy report -> pure numeric input) ---------------.
-
.component_views(composite, runs, coverage_requested:) ⇒ Object
---- component views (every slot, present or not) -------------------------.
-
.coverage_by_path(report) ⇒ Object
path => cold:, cold_ids: from coverage entries (path parsed from symbol_id).
- .coverage_input(report) ⇒ Object
- .dead_code_input(report, index) ⇒ Object
- .diagnostic_for(name, run, coverage_requested) ⇒ Object
- .duplication_input(report, file_count) ⇒ Object
-
.file_rollup(runs, index, churn_present) ⇒ Object
---- per-file roll-up (the cross-analysis join surface) -------------------.
-
.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.
- .file_view(path, hotspot, dead_findings, clone, coverage, boundaries, symbol_count, churn_present) ⇒ Object
-
.run ⇒ Object
Run one analysis in isolation: success carries the report, any failure carries the message so the component degrades rather than the whole run.
- .tracked?(coverage) ⇒ Boolean
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.
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
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 |
.run ⇒ Object
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.) end |
.tracked?(coverage) ⇒ Boolean
267 268 269 |
# File 'lib/moult/health.rb', line 267 def tracked?(coverage) (coverage[:hot] + coverage[:cold]).positive? end |