Skip to content

Maintainability Rules

The Maintainability Index combines several metrics into a single score that indicates how easy the code is to maintain. Higher scores are better -- the opposite of most other rules.

The analysis keeps its Halstead input, MI derivation, and threshold policy in one maintainability pipeline, so the score and its supporting evidence stay aligned.


Maintainability Index

Rule ID: maintainability.mi

Judged metric: maintainability.mi

What it measures

The Maintainability Index (MI) is a composite score that combines three factors:

  • Halstead Volume -- how much "information" is in the code (based on the number of operators and operands)
  • Cyclomatic Complexity -- how many independent paths through the code exist
  • Method statement count -- the formatting-independent semantic size of the method

These three factors are combined into a single number. The original formula produces values on a 0--171 scale, though in practice most code falls between 0 and 100.

How to read the score:

Score Meaning
85--100+ Excellent -- easy to understand and modify
65--84 Good -- reasonable maintainability
40--64 Moderate -- could benefit from simplification
20--39 Poor -- difficult to maintain, refactoring recommended
Below 20 Critical -- very hard to maintain, refactoring required

Inverted thresholds

Unlike most rules where higher values are worse, here lower values are worse. The rule triggers when the score drops below the threshold.

Thresholds

Score Severity Meaning
40+ OK Maintainable code
20--39 Warning Maintainability is deteriorating
Below 20 Error Code is very hard to maintain

Example

A method with low maintainability (MI around 15):

public function processOrder(array $items, array $discounts, ?Customer $customer): array
{
    $result = [];
    $total = 0;
    $taxRate = 0.0;

    if ($customer !== null) {
        if ($customer->isPremium()) {
            $taxRate = $customer->getRegion() === 'EU' ? 0.20 : 0.15;
            if ($customer->hasLoyaltyCard()) {
                $taxRate *= 0.95;
            }
        } else {
            $taxRate = match ($customer->getRegion()) {
                'EU' => 0.21,
                'US' => 0.08,
                'UK' => 0.20,
                default => 0.10,
            };
        }
    }

    foreach ($items as $item) {
        $price = $item['price'] * $item['quantity'];
        foreach ($discounts as $discount) {
            if ($discount['type'] === 'percentage') {
                if (in_array($item['category'], $discount['categories'], true)) {
                    $price *= (1 - $discount['value'] / 100);
                }
            } elseif ($discount['type'] === 'fixed') {
                if ($price > $discount['min_amount']) {
                    $price -= $discount['value'];
                }
            } elseif ($discount['type'] === 'bogo') {
                if ($item['quantity'] >= 2) {
                    $freeItems = intdiv($item['quantity'], 2);
                    $price -= $freeItems * $item['price'];
                }
            }
        }
        $tax = $price * $taxRate;
        $result[] = [
            'item' => $item['name'],
            'subtotal' => $price,
            'tax' => $tax,
            'total' => $price + $tax,
        ];
        $total += $price + $tax;
    }

    return ['items' => $result, 'total' => $total];
}

This method has high complexity, many lines, and many operators/operands, all of which drive the MI score down.

How to fix

  1. Extract helper methods. Break the long method into smaller, named pieces:

    public function processOrder(array $items, array $discounts, ?Customer $customer): array
    {
        $taxRate = $this->calculateTaxRate($customer);
        $result = [];
        $total = 0;
    
        foreach ($items as $item) {
            $lineItem = $this->processLineItem($item, $discounts, $taxRate);
            $result[] = $lineItem;
            $total += $lineItem['total'];
        }
    
        return ['items' => $result, 'total' => $total];
    }
    
  2. Reduce branching. Replace nested if/else chains with early returns, strategy pattern, or polymorphism.

  3. Use value objects. Replace arrays with typed objects to reduce the number of raw operations.

Tip

The minStatements option (default: 10) filters out trivially small methods. Simple getters and setters would produce extreme MI scores that are meaningless. Adjust this if you get too many false positives on small methods.

Implementation notes

The Maintainability Index uses the Oman-Hagemeister formula:

MI = 171 - 5.2 x ln(V) - 0.23 x CCN - 16.2 x ln(LOC)

Where:

  • V = Halstead Volume (a measure of information content based on operators and operands)
  • CCN = Cyclomatic Complexity (CCN2+ variant)
  • LOC input = size.method-statement-count, a formatting-independent count of executable and control-flow statements

The raw MI value (0-171 scale) is normalized to a 0-100 scale: max(0, MI x 100 / 171).

Scope: MI is calculated per method, then aggregated to class/namespace/project level using average and minimum values.

Health score mapping: The health.maintainability dimension uses a penalty-based formula that considers MI average (base quality), MI 5th percentile (main differentiator for outlier methods), and MI minimum (extreme outliers). This multi-term approach produces good discrimination across projects — from well-maintained libraries (score ~95) to complex frameworks (score ~48). See Health Scores for details and customization options.

Deviation from original spec: statement-count input

Qualimetrix uses the dedicated size.method-statement-count metric for the formula's size term. It counts semantic executable/control-flow statements, not physical lines or the old Halstead-owned methodLoc. This keeps MI stable across formatting changes but changes existing MI values, aggregates, health scores, and baselines.

Halstead Volume: semantic approach

Halstead Volume (the V in the formula) uses a semantic interpretation of Halstead's methodology (1977). Qualimetrix counts only elements that carry semantic meaning (arithmetic, logical, comparison operators; variables, literals, constants) and excludes syntactic delimiters (;, (), {}, ,). The original Halstead paper counted all tokens, but was designed for languages (Fortran, PL/I) with minimal syntactic noise. Tools that count all tokens (e.g., pdepend) report significantly higher Volume/Difficulty/Effort values. This does not affect relative comparisons between methods within the same project.

Configuration

Option Default Description
enabled true Enable or disable this rule
warning 40.0 Score below this triggers a warning
error 20.0 Score below this triggers an error
excludeTests true Skip test files
minStatements 10 Skip methods with fewer statements (avoids noise)
# qmx.yaml
rules:
  maintainability.mi:
    warning: 40
    error: 20
    exclude_tests: true
    min_statements: 10
bin/qmx check src/ --rule-opt="maintainability.mi:warning=35"
bin/qmx check src/ --rule-opt="maintainability.mi:min_statements=15"

For a simple pass/fail threshold instead of separate warning/error levels (threshold cannot be combined with warning or error — mixing them is a configuration error and the run stops with exit code 3):

rules:
  maintainability.mi:
    threshold: 20   # warning=20, error=20 → all violations below 20 are errors
bin/qmx check src/ --rule-opt="maintainability.mi:threshold=20"