Module: Moult::ABC

Defined in:
lib/moult/abc.rb

Overview

Flog-style weighted ABC complexity for a single method.

This is not the bare ABC metric (sqrt(A^2 + B^2 + C^2)). Following flog, the score is a weighted sum of three buckets, with metaprogramming calls penalised and a compounding depth penalty for nesting:

* A - Assignments: any write node (`=`, op-assign, `||=`, multi-assign,
`obj.x =`, `arr[i] =`). Weight {ASSIGNMENT}. Counted once per write node.
* B - Branches: every message send ({Prism::CallNode}, including operators
like `+` and `==` and index `[]`), plus `yield` and `super`. Weight
{BRANCH}, except metaprogramming calls in {MAGIC_CALLS}, which weigh more.
* C - Conditions: decision nodes - if/unless/while/until/for, case + each
when/in, rescue, and `&&`/`||`. Weight {CONDITION}.

Depth penalty: contributions nested inside a control structure or block are multiplied by DEPTH_FACTOR per level, compounding. A call directly in the method body weighs 1.0; the same call one if deep weighs 1.1; two deep, 1.21; and so on.

flog is the reference for the shape of this metric; the exact weights below are the ones Moult adopts and are pinned by hand-counted fixtures. Treat any drift from those fixtures as a metric bug.

Constant Summary collapse

ASSIGNMENT =
1.0
BRANCH =
1.0
CONDITION =
1.0
DEPTH_FACTOR =

Each level of control-flow / block nesting compounds contributions by 10%.

1.1
MAGIC_CALLS =

Metaprogramming and dynamic-dispatch calls weigh more than an ordinary send, mirroring flog's penalties for hard-to-follow Ruby.

{
  eval: 5.0,
  instance_eval: 5.0,
  class_eval: 5.0,
  module_eval: 5.0,
  class_exec: 5.0,
  instance_exec: 5.0,
  define_method: 4.0,
  define_singleton_method: 4.0,
  method_missing: 4.0,
  alias_method: 2.0,
  send: 3.0,
  __send__: 3.0,
  public_send: 3.0
}.freeze
BRANCH_NODES =
[
  Prism::CallNode,
  Prism::YieldNode,
  Prism::SuperNode,
  Prism::ForwardingSuperNode
].freeze
CONDITION_NODES =
[
  Prism::IfNode,
  Prism::UnlessNode,
  Prism::WhileNode,
  Prism::UntilNode,
  Prism::ForNode,
  Prism::CaseNode,
  Prism::CaseMatchNode,
  Prism::WhenNode,
  Prism::InNode,
  Prism::RescueNode,
  Prism::AndNode,
  Prism::OrNode
].freeze
NESTING_NODES =

Nodes whose children sit one nesting level deeper. Containers only - the when/in/&&/|| conditions don't bump again on top of their container.

[
  Prism::IfNode,
  Prism::UnlessNode,
  Prism::WhileNode,
  Prism::UntilNode,
  Prism::ForNode,
  Prism::CaseNode,
  Prism::CaseMatchNode,
  Prism::RescueNode,
  Prism::BlockNode,
  Prism::LambdaNode
].freeze

Class Method Summary collapse

Class Method Details

.assignment?(node) ⇒ Boolean

Every Prism assignment node class ends in "WriteNode" (plain writes, operator writes, ||=/&&= writes, multi-writes, and index/attr writes).

Returns:

  • (Boolean)


129
130
131
# File 'lib/moult/abc.rb', line 129

def assignment?(node)
  node.class.name.end_with?("WriteNode")
end

.score(def_node) ⇒ Float

Returns the method's weighted ABC score, rounded to 2 decimals.

Parameters:

  • def_node (Prism::DefNode)

    a method definition

Returns:

  • (Float)

    the method's weighted ABC score, rounded to 2 decimals



95
96
97
98
# File 'lib/moult/abc.rb', line 95

def score(def_node)
  total = walk(def_node, 1.0, root: true)
  total.round(2)
end

.walk(node, multiplier, root: false) ⇒ Object

Recursively accumulate weighted contributions. Nested defs are scored independently (they're separate methods), so we don't descend into them.



102
103
104
105
106
107
108
109
110
111
# File 'lib/moult/abc.rb', line 102

def walk(node, multiplier, root: false)
  return 0.0 if node.is_a?(Prism::DefNode) && !root

  total = weight_for(node) * multiplier
  child_multiplier = NESTING_NODES.include?(node.class) ? multiplier * DEPTH_FACTOR : multiplier
  node.compact_child_nodes.each do |child|
    total += walk(child, child_multiplier)
  end
  total
end

.weight_for(node) ⇒ Object

The weight this node itself contributes (before the depth multiplier).



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

def weight_for(node)
  case node
  when Prism::CallNode
    MAGIC_CALLS.fetch(node.name, BRANCH)
  else
    return BRANCH if BRANCH_NODES.include?(node.class)
    return ASSIGNMENT if assignment?(node)
    return CONDITION if CONDITION_NODES.include?(node.class)

    0.0
  end
end