Module: Moult::Scoring
- Defined in:
- lib/moult/scoring.rb
Overview
Aggregates the per-method ABC and per-file churn into a ranked Report.
File complexity is the sum of its methods' ABC; the file score is complexity x churn. This raw product is dominated by outliers - acceptable for v0.1, but Scoring.combine is isolated so a normalisation strategy (log, rank, z-score) can drop in later without touching the rest of the pipeline.
Files with no methods (or only zero-scoring ones) are omitted: they cannot be a complexity hotspot. Ranking is score-descending, with complexity then path as deterministic tie-breakers (so 0-churn files - e.g. outside a repo - still order by complexity rather than arbitrarily).
Constant Summary collapse
- DEFAULT_WORST_METHODS =
3
Class Method Summary collapse
- .build_method(method_def, rel) ⇒ Object
- .build_report(root:, files:, churn:, worst_methods: DEFAULT_WORST_METHODS, git_ref: nil, generated_at: nil, churn_window: nil, churn_since: nil, edges: nil) ⇒ Report
-
.combine(complexity, churn) ⇒ Numeric
The v0.1 scoring rule.
-
.coupling_degrees(edges) ⇒ Object
In/out degree per file over the unique dependency pairs.
-
.coupling_fields(rel, coupling) ⇒ Object
A file absent from the edge set is measured-as-isolated (0/0), not unknown: the index saw it and found no resolved dependencies.
-
.hotspot_for(abs, root:, churn:, worst_methods:, coupling: nil) ⇒ Report::Hotspot?
Nil when the file has no scoring methods.
- .relative_path(abs, root) ⇒ Object
Class Method Details
.build_method(method_def, rel) ⇒ Object
99 100 101 102 103 104 105 106 |
# File 'lib/moult/scoring.rb', line 99 def build_method(method_def, rel) Report::Method.new( symbol_id: SymbolId.for(path: rel, start_line: method_def.span.start_line, fqname: method_def.name), name: method_def.name, span: method_def.span, abc: ABC.score(method_def.node) ) end |
.build_report(root:, files:, churn:, worst_methods: DEFAULT_WORST_METHODS, git_ref: nil, generated_at: nil, churn_window: nil, churn_since: nil, edges: nil) ⇒ Report
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
# File 'lib/moult/scoring.rb', line 29 def build_report(root:, files:, churn:, worst_methods: DEFAULT_WORST_METHODS, git_ref: nil, generated_at: nil, churn_window: nil, churn_since: nil, edges: nil) coupling = coupling_degrees(edges) hotspots = files.filter_map do |abs| hotspot_for(abs, root: root, churn: churn, worst_methods: worst_methods, coupling: coupling) end hotspots.sort_by! { |h| [-h.score, -h.complexity, h.path] } Report.new( root: root, hotspots: hotspots, git_ref: git_ref, generated_at: generated_at, churn_window: churn_window, churn_since: churn_since ) end |
.combine(complexity, churn) ⇒ Numeric
The v0.1 scoring rule. Swap-point for future normalisation. Coupling is deliberately not a factor: weighting by instability would bury complex, churned, high-fan-in files — exactly the hotspots that matter most.
71 72 73 |
# File 'lib/moult/scoring.rb', line 71 def combine(complexity, churn) complexity * churn end |
.coupling_degrees(edges) ⇒ Object
In/out degree per file over the unique dependency pairs. nil when no edges were supplied (coupling unknown).
77 78 79 80 81 82 83 |
# File 'lib/moult/scoring.rb', line 77 def coupling_degrees(edges) return nil unless edges { out: edges.group_by(&:src).transform_values(&:size), in: edges.group_by(&:dst).transform_values(&:size) } end |
.coupling_fields(rel, coupling) ⇒ Object
A file absent from the edge set is measured-as-isolated (0/0), not unknown: the index saw it and found no resolved dependencies.
87 88 89 90 91 92 93 94 95 96 97 |
# File 'lib/moult/scoring.rb', line 87 def coupling_fields(rel, coupling) return {fan_in: nil, fan_out: nil, instability: nil} unless coupling fan_in = coupling[:in][rel] || 0 fan_out = coupling[:out][rel] || 0 total = fan_in + fan_out { fan_in: fan_in, fan_out: fan_out, instability: total.zero? ? 0.0 : (fan_out.to_f / total).round(2) } end |
.hotspot_for(abs, root:, churn:, worst_methods:, coupling: nil) ⇒ Report::Hotspot?
Returns nil when the file has no scoring methods.
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/moult/scoring.rb', line 48 def hotspot_for(abs, root:, churn:, worst_methods:, coupling: nil) rel = relative_path(abs, root) methods = Parser.parse_file(abs).map { |m| build_method(m, rel) } complexity = methods.sum(0.0, &:abc) return nil if complexity.zero? churn_count = churn[rel] kept = methods.sort_by { |m| -m.abc }.first(worst_methods) Report::Hotspot.new( path: rel, score: combine(complexity, churn_count).round(2), complexity: complexity.round(2), churn: churn_count, methods: kept, **coupling_fields(rel, coupling) ) end |
.relative_path(abs, root) ⇒ Object
108 109 110 |
# File 'lib/moult/scoring.rb', line 108 def relative_path(abs, root) SymbolId.relative_path(abs, root) end |