Health Scores¶
Qualimetrix computes six health scores for every class, namespace, and project — each ranging from 0 (worst) to 100 (best). Health scores distill dozens of raw metrics into a quick quality overview, helping you spot problems without reading individual metric values.
Definitions are resolved per analysis run and evaluated after raw metric aggregation. Reusing a process for multiple runs replaces the prior definition set atomically, so configuration from an earlier run cannot leak into the next.
Rule ID: computed — user-defined computed metrics.
Each built-in dimension is its own producer, and publishes its findings under its own rule ID:
- Rule ID:
health.complexity - Rule ID:
health.cohesion - Rule ID:
health.coupling - Rule ID:
health.typing - Rule ID:
health.maintainability - Rule ID:
health.overall
Dimensions¶
| Dimension | What it measures | Key input metrics | Default thresholds (warning / error) |
|---|---|---|---|
health.complexity |
Method and class complexity | CCN (avg, max, p95), Cognitive Complexity (avg, max, p95) | 50 / 25 |
health.cohesion |
How well class methods relate to each other | TCC, LCOM4, method count | 50 / 25 |
health.coupling |
Dependencies between classes and namespaces | Efferent coupling (Ce, Ce packages), Distance from Main Sequence, CBO (project level) | 50 / 25 |
health.typing |
Type declaration coverage | Parameter, return, and property type coverage | 80 / 50 |
health.maintainability |
Ease of safe modification | Maintainability Index (avg, p5, min) | 50 / 25 |
health.overall |
Weighted average of all dimensions | All of the above | 50 / 30 |
Score Labels¶
Every health score is assigned a human-readable label based on the score value relative to the warning (W) and error (E) thresholds:
- Excellent: score > W + (100 - W) x 0.6
- Good: score > W + (100 - W) x 0.3
- Fair: score > W
- Poor: score > E
- Critical: score <= E
For the most common defaults (W=50, E=25):
| Label | Score range |
|---|---|
| Excellent | > 80 |
| Good | 65 -- 80 |
| Fair | 50 -- 65 |
| Poor | 25 -- 50 |
| Critical | <= 25 |
Note
health.typing uses different thresholds (W=80, E=50), so its label boundaries shift accordingly: Excellent > 92, Good > 86, Fair > 80, Poor > 50, Critical <= 50.
How Scores Work¶
All health scores start from 100 and subtract penalties for metrics that exceed healthy thresholds. Each dimension has level-specific formulas — class, namespace, and project levels use different inputs because different aggregation statistics are available. Namespace and project formulas use aggregated statistics (.avg, .p95, .max, .min, .p5) while class formulas use raw per-class values.
Formulas are written in Symfony Expression Language syntax.
Complexity¶
Penalizes high average CCN and cognitive complexity, plus square-root-scaled penalties for outlier methods (max values at class level, p95 at namespace level). Well-structured code with simple methods scores near 100.
Interface methods are included in aggregation
Interface methods have minimal complexity (CCN=1, cognitive=0, NPath=1) and are included in namespace-level .avg and .p95 calculations. Projects with many interfaces may see lower average complexity than expected. This is by design — interfaces are part of the codebase — but means adding interfaces can slightly improve complexity scores without changing actual logic.
Cohesion¶
Blends TCC (Tight Class Cohesion) and LCOM4. TCC is square-root-scaled to reward incremental improvement. Classes with few methods (< 6) get a lenient TCC default. Pure methods (no property access) are accounted for to avoid false penalties.
Coupling¶
Uses hyperbolic decay (K / (K + penalty)) for smooth scoring.
- Class level blends package-level (
coupling.ce-packages) and dampened raw efferent coupling (coupling.ce). - Namespace level also relies on efferent-only signals: per-class average outgoing coupling (
coupling.ce.avg,coupling.ce-packages.avg), worst-case class outlier (coupling.ce.max), and namespace-level outgoing breadth (coupling.ce), plus Distance from Main Sequence. Bidirectional CBO is intentionally avoided here because it conflates afferent (Ca) with efferent (Ce) and would unfairly penalize stable contracts namespaces (high Ca, low Ce by design). - Project level keeps bidirectional CBO aggregates (
coupling.cbo.avg,coupling.cbo.p95,coupling.cbo.max): at project level Σ Ca = Σ Ce because every internal edge contributes to both sides, so CBO is symmetric and proportional to Ce.
Typing¶
At class level, directly maps type coverage percentage. At namespace and project level, computes the ratio from raw typed/total counters to avoid averaging bias.
Maintainability¶
Three-term penalty on MI average (base quality), MI 5th percentile (main differentiator), and MI minimum (extreme outliers). The knees sit at Coleman's published lines: 85, above which a codebase is "highly maintainable", and 65, below which it is "difficult to maintain". Across the seventeen-project calibration corpus the dimension ranges from 33.7 to 100.0 at project level.
Overall¶
Weighted average of the other five dimensions. At class level, maintainability is excluded (its signal is already captured by complexity and cohesion). Weights:
- Class: complexity 35%, cohesion 25%, coupling 25%, typing 15%
- Namespace / Project: complexity 30%, cohesion 20%, coupling 20%, typing 10%, maintainability 20%
Reading Health Scores¶
Health scores appear in several output formats:
- Summary format (
--format=summary, default) — progress bars with color coding and labels - JSON format (
--format=json) —healthScoresarray in the output object - Health format (
--format=health) — text table of health dimensions with scores, status, and decomposition - HTML format (
--format=html) — interactive treemap colored by selected health dimension
See Output Formats for details.
What a Score Covers¶
A score is only a statement about the part of the codebase its inputs could be measured on. Cohesion is undefined for a class with fewer than two methods, so a cohesion score typically describes between a quarter and a half of a project's classes — and said nothing about that until now.
Every health dimension therefore publishes a coverage beside its score: the
.count the narrowest input aggregate reported, the population that count is a
share of, and which .count was reported (basis). Scores are not damped
by coverage; the number is published so a reader can judge it, not folded into
it (see ADR 0062).
Where coverage is undefined the field says so explicitly, with a reason, rather
than reporting zero: health.overall composes the other dimensions, health.typing
is computed from typed/total sums that publish no .count, and a class-level or
namespace-filtered score is not an aggregate over symbols at all.
Coverage appears in --format=json (a coverage object per dimension) and in
--format=health (one line per dimension). The compact formats — summary,
HTML — leave it out for space.
Configuration¶
Accepted Keys¶
Each computed_metrics: entry accepts exactly nine keys: formula, formulas,
levels, description, inverted, threshold, warning, error, and
enabled. threshold sets both warning and error to the same value and
cannot be combined with either of them. Inside formulas:, the only accepted
keys are the three report levels: class, namespace, and project.
An unknown key, a value of the wrong type, or a health.* name outside the
six built-in dimensions (health.complexity, health.cohesion,
health.coupling, health.typing, health.maintainability,
health.overall) is refused with exit code 3 and a message naming what was
written and what is accepted — none of these are ignored silently.
Customizing Thresholds¶
# qmx.yaml
computed_metrics:
health.complexity:
warning: 60 # Stricter than default 50
error: 30 # Stricter than default 25
Disabling a Dimension¶
Or via CLI:
Both paths produce the same result: the dimension is removed from the pipeline AND health.overall weights are renormalized across the remaining dimensions (the disabled dimension is not silently treated as a neutral 75-point contribution). If you override health.overall with a non-canonical formula (e.g. min(...) or a conditional), excluding dimensions will throw an explicit error — handle the disabled dimension via ?? fallbacks in your custom formula instead.
Two switches that look alike, and do different things
Each built-in dimension is its own producer, so it can be turned off two ways that read almost the same:
rules: { health.cohesion: { enabled: false } }stops thehealth.cohesionproducer from publishing findings. The dimension is still computed and still contributes tohealth.overall.computed_metrics: { health.cohesion: { enabled: false } }removes the dimension itself — this is the "Disabling a Dimension" switch above.health.overall's weights are renormalized across what remains.
A dimension removed the second way leaves its producer with no channel at all. An suppress_namespace_channels key that used to address health.cohesion is then rejected: the key must name a channel the rule under it actually emits, and after removal it emits none.
Overriding Formulas¶
computed_metrics:
health.maintainability:
# Same formula for all levels
formula: "clamp(m['maintainability.mi.avg'], 0, 100)"
A formula is an expression written as a string, and a constant is an
expression: formula: "80" is a metric that is 80 everywhere. It has to be
quoted — an unquoted 80 is a number, and a number is not a formula.
computed_metrics:
health.maintainability:
# Different formulas per level
formulas:
class: "clamp(m['maintainability.mi.avg'], 0, 100)"
namespace: "clamp(m['maintainability.mi.avg'] * 0.7 + m['maintainability.mi.p5'] * 0.3, 0, 100)"
project: "clamp(m['maintainability.mi.avg'] * 0.7 + m['maintainability.mi.p5'] * 0.3, 0, 100)"
Custom Computed Metrics¶
computed_metrics:
computed.code-density:
formula: "clamp((m['size.lloc'] ?? 0) / max(m['size.loc'] ?? 1, 1) * 100, 0, 100)"
description: "Ratio of logical to physical lines (higher = denser code)"
levels: [namespace] # size.lloc / size.loc are only raw keys at namespace level
warning: 80
error: 90
inverted: false # Higher values trigger violations
Metric naming
A user-defined metric name must start with health. or computed. — no other prefix is accepted. The recommended convention for custom metrics is computed.*; health.* is reserved for the six built-in dimensions. Both prefixes require lower-case kebab-case segments after the dot (e.g. computed.code-density); underscores and upper-case letters are rejected, and the last segment cannot be the name of an aggregation strategy (sum, avg, max, min, count, p95, p5 — e.g. computed.sum is refused).
Available Variables¶
Formulas read every metric through a single m array, indexed by the metric's real key: m["complexity.ccn.avg"]. There is no separate "variable name" to memorize — the key you see in --format=metrics/--format=json output is the key you index with.
| Metric key | Available at |
|---|---|
complexity.ccn.avg |
class, namespace, project |
complexity.ccn.max |
class, namespace, project |
complexity.ccn.sum |
namespace, project |
complexity.ccn.p95 |
namespace, project |
complexity.cognitive.avg |
class, namespace, project |
complexity.cognitive.max |
class, namespace, project |
complexity.cognitive.sum |
namespace, project |
complexity.cognitive.p95 |
namespace, project |
cohesion.tcc |
class |
cohesion.tcc.avg |
namespace, project |
cohesion.lcom |
class |
cohesion.lcom.avg |
namespace, project |
coupling.cbo.avg |
namespace, project |
coupling.cbo.max |
namespace, project |
coupling.cbo.p95 |
namespace, project |
coupling.ce |
class, namespace |
coupling.ce.avg |
namespace, project |
coupling.ce.max |
namespace, project |
coupling.ce-packages |
class |
coupling.ce-packages.avg |
namespace, project |
coupling.distance |
namespace |
coupling.distance.avg |
project |
maintainability.mi.avg |
class, namespace, project |
maintainability.mi.min |
class, namespace, project |
maintainability.mi.p5 |
namespace, project |
design.type-coverage.all |
class |
design.type-coverage.param.total.sum |
namespace, project |
design.type-coverage.param.typed.sum |
namespace, project |
design.type-coverage.return.total.sum |
namespace, project |
design.type-coverage.return.typed.sum |
namespace, project |
design.type-coverage.property.total.sum |
namespace, project |
design.type-coverage.property.typed.sum |
namespace, project |
size.method-count |
class |
size.symbol-method-count |
class, namespace, project |
cohesion.pure-method-count |
class |
size.loc |
namespace |
size.lloc |
namespace |
health.complexity |
class, namespace, project |
health.cohesion |
class, namespace, project |
health.coupling |
class, namespace, project |
health.typing |
class, namespace, project |
health.maintainability |
class, namespace, project |
Common aggregation suffixes on a key: .avg, .min, .max, .sum, .p5, .p95.
This is not an exhaustive list — any metric collected by Qualimetrix can be referenced in formulas by its key. Use bin/qmx check src/ --format=metrics to see all available metrics and their exact keys for your project.
Unknown metric references
If a formula references a metric key that does not exist (e.g., a typo like m["complexity.ccn.abg"] instead of m["complexity.ccn.avg"]), Qualimetrix will report a clear error instead of silently returning zero. Always use the ?? operator to provide a default for metrics that may legitimately be absent: (m["complexity.ccn.avg"] ?? 0).
Available Functions¶
| Function | Description |
|---|---|
min(a, b) |
Minimum of two values |
max(a, b) |
Maximum of two values |
abs(x) |
Absolute value |
sqrt(x) |
Square root |
log(x) |
Natural logarithm |
log10(x) |
Base-10 logarithm |
clamp(value, min, max) |
Constrain value to [min, max] range |
?? |
Null coalescing (default value if metric is missing) |
** |
Exponentiation |
Always use null coalescing
Metrics may be missing for some symbols (e.g., a class with no methods has no complexity.ccn). Always provide defaults with ??: (m["complexity.ccn.avg"] ?? 1) instead of m["complexity.ccn.avg"].