Health Scores¶
Qualimetrix defines six health dimensions for classes, namespaces and projects. A score ranges from 0 (worst) to 100 (best) when the selected formula has applicable measured inputs. A dimension without those inputs has no numeric score; numeric 0 remains a measurement.
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 | Outgoing coupling (Ce, Ce packages), applicable Distance from Main Sequence | 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¶
Built-in dimensions use level-specific measured inputs, and overall health is their weighted mean. Missing or inapplicable inputs do not become invented penalty values. Namespace and project formulas use aggregated statistics (.avg, .p95, .max, .min, .p5); class formulas use 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¶
Combines available TCC (Tight Class Cohesion) and LCOM4 contributions and divides by their participating weight sum. An absent half contributes no default, including for classes with fewer than six methods. TCC retains its square-root scaling; its purity adjustment divides by cohesion.tcc-method-count, not size.method-count. Classes with neither measured TCC nor LCOM have no cohesion score.
Namespace and project cohesion apply the same pure-method correction using
cohesion.pure-method-count.avg and cohesion.tcc-method-count.avg; a namespace
containing one class receives that class's score. Health does not inherit the
LCOM rule's minimum-method filter. A nonempty one-vertex graph gives the LCOM contribution 100 by
construction (the whole score is 100 when TCC is absent); a two-vertex disconnected graph without purity adjustment scores
40. These describe graph shape, not sufficient evidence of a responsibility
split. In the reviewed corpus, one-vertex classes were 209/877 in Qualimetrix,
164/242 in PHP-Parser, 359/826 in PHPUnit and 153/856 in Laravel.
The class and namespace LCOM contributions use span five. The declared 27-candidate search retained the earlier namespace scale. Project cohesion inherits that namespace formula and evaluates project aggregate inputs directly; it does not average namespace health scores. Correctly pooled complementary TCC/LCOM inputs can score above every child even with the same formula and contributor population. ADR 0116 records the native example (50/50 → 60.355339) and the retained checks for actual input loss. Parent/child comparisons remain diagnostic.
Measured calibration
The selected tuple uses LCOM span five, project distance weight six and breadth weight four. ADR 0116 records all 27 candidates and the complete 17-project before/after tables. Review dependent limits against the changed measured populations.
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 uses outgoing-only Form B: mean outgoing package breadth
(
coupling.ce-packages.avg), square-root-scaledcoupling.ce.avg, outgoing outliers (coupling.ce.max), and applicablecoupling.distance-own.avg. The distance is weighted by eligible types in measured own scopes. Incoming popularity is not a project penalty; bidirectional CBO is not an input.
On the 17-project calibration corpus, the breadth penalty was zero in every project: its combined input never reached the knee four (maximum 2.996722 in Guzzle). Breadth weight four was selected by grid-order tie-break, not by a measured corpus effect. On that corpus the penalty therefore follows own distance and the worst outgoing class; no broader discrimination below the knee is promised.
Project Form B uses distance weight six and breadth weight four:
clamp(100 * 18 / (18 + (m["coupling.distance-own.avg"] ?? 0) * 6 + max((m["coupling.ce-packages.avg"] ?? 0) * 3.0 + (m["coupling.ce.avg"] ?? 0) ** 0.5 * 0.5 - 4, 0) * 4 + max((m["coupling.ce.max"] ?? 0) - 30, 0) ** 0.5 * 0.8), 0, 100)
Applicability requires at least one finite measured input among
coupling.distance-own.avg, coupling.ce-packages.avg, coupling.ce.avg and
coupling.ce.max. Its decomposition uses those same four inputs. An absent
distance remains absent in metrics and coverage; the expression's missing-term
fallback does not publish a measured zero.
Class coupling remains applicable at measured 0. Namespace coupling requires at least one finite measured input among coupling.distance, coupling.ce-packages.avg, coupling.ce.avg, coupling.ce.max and coupling.ce. A function-only namespace with all five absent has no built-in coupling score. When no applicable distance is published, the formula uses only its remaining measured inputs; an edge-free namespace is not assigned distance 1.
Typing¶
Uses a class percentage when there is something to type. Namespace and project scores divide the summed typed counters by the actual summed parameter, return and property totals. A zero total produces no typing score; a positive total with zero typed declarations produces 0.
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". The previously measured seventeen-project calibration corpus ranged from 33.7 to 100.0 at project level; this is historical calibration evidence.
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% Overall averages only available dimensions, preserving the listed order and original weights and dividing by their participating weight sum. Missing dimensions receive no neutral 75-point value; measured 0 participates. With no participating dimension, overall has no value.
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) —healthobject in the output document - 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 describes the part of the codebase its participating inputs cover. If TCC is absent and measured LCOM supplies cohesion, the score's coverage follows LCOM rather than the missing TCC half. Coverage and decomposition follow the selected effective formula, including the inputs an authored formula actually reads; a builtin name alone does not establish its evidence.
Every health dimension therefore publishes a coverage beside its score: the
.count the narrowest participating 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).
The states are measured, not-measured and not-applicable. A positive
eligible population with no measured input reports not-measured as 0/N; an
empty population reports not-applicable with a reason. Numeric 0 is a measured
value. Enabled builtin project dimensions remain visible with a null score
when unavailable; disabled dimensions are omitted. Missing decomposition
values are null and retain their own coverage, rather than displaying numeric
0. Selecting a class or namespace never replaces its absent score with a
project score.
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.
Class inputs and size.symbol-class-count count declarations, including
separate declarations of the same name. Their sample counts use that same
population; coverage is not clamped to hide a mismatch.
Deviation from original spec
Coupling graph metrics remain defined over logical names. When a name has several declarations, its graph values are sampled once per declaration in namespace and project aggregates. This is a declaration-weighted extension of the logical graph metrics.
Coverage appears in --format=json (a coverage object per dimension), in
--format=health (a Coverage column plus one line per dimension in the
decomposition), in --format=summary (one line under each score) and in
--format=html (summary.healthCoverage and prepared
summary.healthDecomposition beside summary.healthScores). The viewer
renders this PHP evidence without evaluating formulas in JavaScript.
Cohesion contributors use TCC for the whole selected scope when any candidate has it, including measured zero; otherwise they rank by LCOM, larger values first. Candidates missing the selected metric are omitted. A run with no aggregated project metrics publishes no health projection: its populations are unknown rather than zero.
A coverage short of 100% is not automatically a fault in the run. Some gaps are permanent by construction: TCC can be absent on small classes, and a namespace that declares nothing but bare enums has no abstractness of its own — a bare enum is deliberately outside that denominator (see Distance from Main Sequence) — so no own-scope distance is published for it, while the population counts it because it declares a type. The denominator is deliberately not narrowed to the namespaces the aggregate reached: a denominator that is the aggregate's own walk prints 100% by construction and cannot show what the walk did not reach, which is the whole point of the line.
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 in the same layer. 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. A key is
recognised even when its value is ~, so warnign: ~ is refused too.
Across presets and your file¶
computed_metrics merges metric by metric, and inside one metric key by key:
a preset's metric and your file's keys for the same name combine, and a metric
another layer never names is left as it is.
thresholdis expanded intowarninganderrorin the layer that wrote it, so a file that writes onlywarningover a preset'sthresholdkeeps the preset's value aserror.formulasets every level andformulas.<level>refines one level, whichever layers wrote them: a file'sformulas: {class: …}over a preset'sformulachanges the class level only.- A metric is removed only by
enabled: false.computed_metrics: {}andhealth.complexity: ~leave the lower layer's values standing — neither resets anything to the built-in defaults. exclude_healthaccumulates across layers.
The rules every configuration key follows are in How layers combine.
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 remove the dimension and its canonical overall term. Original weights and term order remain unchanged; the weighted mean divides by the participating weight sum. An unsupported authored overall formula refuses with its selected formula source. Rewrite it as a supported canonical weighted mean or explicitly handle availability in the custom formula.
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.
An authored effective formula remains authored even when copied verbatim from a built-in. It uses Always for the selected formula and does not require builtin inputs for a constant 80. Metadata-only overrides retain builtin applicability. The constant cannot claim builtin decomposition or coverage; without an identifiable measured symbol population its coverage is not-applicable.
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-method-count |
class |
cohesion.tcc-method-count |
class |
cohesion.unread-trait-count |
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.abstractness |
namespace |
coupling.distance |
namespace |
coupling.ca-own |
namespace |
coupling.ce-own |
namespace |
coupling.instability-own |
namespace |
coupling.abstractness-own |
namespace |
coupling.distance-own |
namespace |
coupling.distance-own.avg |
project |
coupling.distance-own.count |
project |
size.symbol-declaring-namespace-count |
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.
coupling.instability-own (like coupling.cbo-own) is available only on a namespace that declares a type of its own: on any other namespace the key is absent, not 0. A formula that reads m["coupling.instability-own"] without ?? therefore gets no value on such a namespace, which is listed under computedMetricOutcomes; write m["coupling.instability-own"] ?? 0 to keep a value there. A namespace that declares a type but has no own edge (Ca-own = Ce-own = 0) carries 0 by convention.
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. Use ?? when a fallback is meaningful for your formula; absent inputs differ from choosing 0 or a neutral value.
Metrics a level does not carry
A formula runs at each of its levels:, and a key is judged at that level:
- No symbol at the level carries the key, and the formula reads it without
??— a configuration error (exit code 3). This includes another computed metric read at a level missing from its ownlevels::computed.awithlevels: [class]cannot be read bare by aprojectformula, and the error says wherecomputed.ais published. Aprojectlevel that inherits thenamespaceformula is checked atproject. - Some symbols carry the key and others do not — the symbols without it get no value rather than a fabricated 0, and the successful report carries a structural summary by metric and level, with separate missing-input and null-result counts, exact missing keys and at most three deterministic exact subject samples.
m["a"] ?? m["b"] reads b only where a is absent, so a symbol is skipped only when it carries neither. End the chain with a literal — m["a"] ?? m["b"] ?? 0 — to give every symbol a value.
A ternary reads only the branch it takes. m["size.method-count"] > 0 ? 7 : m["cohesion.tcc"] reads cohesion.tcc only on a class without methods, so a key only one branch reads is never a configuration error: each symbol is judged by the branch its own values select, and a symbol whose branch reads a key it lacks enters the same absence summary. The right side of and / or is judged the same way. The condition always runs, so a bare read there counts like one in arithmetic — an absent metric would pick the branch on nothing (null > 0 is false); guard it with ?? as well. A key both branches read counts as read.
weighted_mean takes ordered alternating nullable values and positive finite numeric weights. It skips only null values, preserves 0 and returns null when no value participates. Only the exact enclosing clamp of a weighted mean preserves that null result; ordinary clamp of null fails. Other functions retain native PHP semantics. Builtin inapplicability is quiet; applicable builtin absence and evaluation failures refuse with the effective formula source and exit 3, without a successful partial report.
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 |
weighted_mean(value, weight, ...) |
Mean of participating nullable values with positive finite weights |
?? |
Null coalescing (default value if metric is missing) |
** |
Exponentiation |
Choose meaningful defaults
Use ?? when the fallback has meaning for your formula. Leaving an input absent is distinct from choosing 0 or a neutral value; nullable weighted-mean values can remain absent.
Per-class boundary¶
A class docblock may set a finite boundary with
@qmx-threshold health.cohesion 40 (or another exact built-in health rule).
Only one boundary is supported: use a number or equal warning/error values.
This affects that declaration's findings, keeps namespace/project thresholds
configured above, and remains visible to qmx directives. User-defined
computed.* metrics retain configuration-level thresholds.