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¶
-
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]; } -
Reduce branching. Replace nested
if/elsechains with early returns, strategy pattern, or polymorphism. -
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:
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) |
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):