Coupling Rules¶
Coupling rules measure how tightly your classes depend on each other. When classes are tightly coupled, changing one class can break many others. Loosely coupled code is easier to test (you can isolate a class), easier to change (fewer side effects), and easier to reuse.
The same Coupling analysis also classifies configured framework dependencies for
the coupling.cbo-app and coupling.ce-framework metrics. A later configuration source replaces
the earlier framework namespace list; an explicit empty list disables that
classification for the run.
Think of coupling like wires connecting boxes. The more wires between two boxes, the harder it is to move one without disturbing the other.
CBO -- Coupling Between Objects¶
Rule ID: coupling.cbo
Judged metrics: coupling.cbo, coupling.cbo-app, coupling.cbo-own
What it measures¶
CBO counts the total number of other classes that this class is connected to. A "connection" means either:
- This class uses another class (outgoing dependency, called "efferent coupling" or Ce), or
- Another class uses this class (incoming dependency, called "afferent coupling" or Ca)
CBO = |Ca ∪ Ce| (the number of unique classes in the union of both sets).
For example, if UserService uses UserRepository, Logger, Validator, and Mailer (Ce = 4), and is used by UserController and AdminController (Ca = 2), its CBO = 6 (no overlap).
How to read the value:
| CBO | Interpretation |
|---|---|
| 0--7 | Normal coupling |
| 8--14 | Moderate -- typical for complex classes |
| 15--20 | High coupling -- consider reducing dependencies |
| 20+ | Very high coupling |
At namespace level CBO counts namespaces, not classes. A namespace's CBO is the number of distinct namespaces holding a class on the other side of a dependency that crosses the namespace's boundary, in either direction. The boundary is the namespace's whole subtree -- the same region its coupling.ca and coupling.ce are counted over -- so a parent namespace is coupled to what lies outside it, never to its own sub-namespaces. coupling.ca and coupling.ce on a namespace still count classes, which is why a namespace's CBO is usually smaller than either, and why the finding message spells it CBO: N namespaces.
The other side is named by the namespace that declares the class, so the number depends on how finely the code around the namespace is split: dividing a neighbouring directory into sub-namespaces raises this namespace's CBO while its Ca and Ce stay the same. A parent namespace near the root of a project is coupled to many such namespaces and usually has the highest CBO of all. The namespace thresholds below reuse the class-level numbers; they were not calibrated for this quantity separately.
A namespace is judged on its own classes. The rule reads coupling.cbo-own: the same count taken over the classes declared directly in the namespace, where a sub-namespace is a namespace like any other. A namespace without sub-namespaces has one scope, so the two keys are equal there. On a parent they differ, and the subtree value is not judged: it grows with the subtree, and its region depends on which sub-namespaces the run holds, so a narrowed run and a whole one would disagree about which code is judged. The own scope's boundary does not move with that, so which namespaces are judged does not depend on the run's paths. The value does, as it does for every coupling measure (coupling.ca, coupling.instability, coupling.class-rank): a dependency is read from the class it starts at, so a class the run did not analyse — a sub-namespace's included — is not counted, and a narrowed run (projectScope.state: narrowed in the JSON report) can judge a namespace on a lower value than a whole one. The finding's inbound and outbound counts are the own scope's too (coupling.ca-own, coupling.ce-own). A namespace that declares no type of its own publishes no coupling.cbo-own and is not judged; the subtree coupling.cbo is published on every namespace (--format=metrics).
Thresholds¶
Class level (enabled by default):
| Level | Threshold | Severity |
|---|---|---|
| Warning | >= 14 | Warning |
| Error | >= 20 | Error |
Namespace level (enabled by default, judged on coupling.cbo-own, requires at least 3 classes declared in the namespace itself):
| Level | Threshold | Severity |
|---|---|---|
| Warning | >= 14 | Warning |
| Error | >= 20 | Error |
Example¶
class ReportGenerator
{
public function __construct(
private UserRepository $userRepo, // +1
private OrderRepository $orderRepo, // +1
private ProductRepository $productRepo, // +1
private Logger $logger, // +1
private CacheInterface $cache, // +1
private EventDispatcher $dispatcher, // +1
private TemplateEngine $templates, // +1
private PdfGenerator $pdf, // +1
private CsvExporter $csv, // +1
private EmailService $email, // +1
private TranslatorInterface $translator, // +1
private ConfigProvider $config, // +1
private MetricsCollector $metrics, // +1
private SecurityChecker $security, // +1
private AuditLogger $audit, // +1
) {}
// Ce = 15, and if nothing depends on this class, CBO = 15 -> WARNING
}
How to fix¶
- Split the class. A class with 15+ dependencies is doing too much. Extract groups of related dependencies into focused services (e.g.,
ReportDataProvider,ReportExporter). - Use interfaces instead of concrete classes. This does not reduce CBO directly, but makes the coupling looser and more flexible.
- Apply dependency injection. Avoid creating dependencies inside the class with
new. Inject them through the constructor so they can be replaced. - Consider the Facade pattern. Wrap groups of related services behind a single interface.
Implementation notes¶
Qualimetrix implements bidirectional coupling consistent with Chidamber & Kemerer (1994): CBO = |Ca ∪ Ce|, counting the number of unique classes that appear in either incoming or outgoing dependencies. If class A both uses and is used by class B, B is counted once (union semantics), not twice.
- Extended coupling types: Qualimetrix detects 14 types of coupling, going beyond C&K's original "methods or instance variables" definition. These include: class instantiation, static method calls, type hints (parameters, return types, properties),
catchclauses,instanceofchecks, class constants, attributes (the attribute name and, as a separate kind, the references inside its arguments),extends/implements, and traituse. - Union and intersection types: Each type in a union (
A|B) or intersection (A&B) type hint is counted as a separate coupling. - Self-references excluded: References to
self,static, andparentwithin the same class are not counted as coupling. - PHP built-in classes excluded: Dependencies on classes from the PHP distribution (php-src) are excluded from CBO, Ca, and Ce — this includes core classes (
Exception,DateTime,Closure), SPL (ArrayIterator,SplFileInfo), and bundled extensions (PDO,DOMDocument,Random\Randomizer,CurlHandle, etc.). Coupling to stable, PHP-maintained types does not increase architectural risk. Classes from PECL extensions (e.g.,Redis,Memcached,MongoDB\Driver\Manager) are not excluded and count toward CBO as regular dependencies. The exclusion does not depend on how the class is named:extends \RuntimeExceptioncounts toward CBO, Ca and Ce no more thanimplements \Countableor a\DateTimeImmutabletype hint does. Theextendsedge itself is kept, because DIT and NOC read inheritance from it.
Deviation from original spec
The graph universe includes every named project class, interface, trait, and enum, even when it has no edges. CBO, Ca, and Ce deduplicate logical endpoints and retain undeclared external targets; degree-zero declarations therefore receive known zero values instead of missing metrics. A logical class score is projected to each exact owned declaration so declaration controls, baseline identities, and fingerprints remain independent.
Configuration¶
# qmx.yaml
rules:
coupling.cbo:
suppress_namespaces:
- subtree: App\Core\ValueObject
scope: application # 'all' (default) or 'application' (uses CBO_APP)
class:
warning: 18
error: 25
namespace:
enabled: true
min_class_count: 5
For a simple pass/fail threshold on a single level:
bin/qmx check src/ --rule-opt="coupling.cbo:class.warning=18"
bin/qmx check src/ --rule-opt="coupling.cbo:class.error=25"
bin/qmx check src/ --rule-opt="coupling.cbo:namespace.min_class_count=5"
bin/qmx check src/ --rule-opt="coupling.cbo:namespace.enabled=false"
bin/qmx check src/ --rule-opt="coupling.cbo:scope=application"
Flat threshold shorthand (both levels at once): a bare threshold at the
rule's own top level -- not nested under class:/namespace: -- sets
warning/error to the same value on both the class and namespace
dimensions in one go. This differs from complexity.ccn's top-level
threshold, which applies to the callable level only: CBO's class/namespace
defaults already match (14/20), so there is no single "primary" level to
prefer.
Shorthand expands before configuration layers merge
A top-level threshold sets both the class and namespace warning/error
leaves in its own configuration layer. Writing it beside an overlapping
nested threshold or band in that same layer refuses with exit 3; independent
settings such as class.enabled remain valid. Across layers, the later
layer replaces only the leaves it writes: threshold: 50 in qmx.yaml
followed by --rule-opt="coupling.cbo:class.warning=1" and
--rule-opt="coupling.cbo:class.error=1" sets the class band to 1/1 while
leaving the namespace band at 50/50.
Top-level warning: / error: are options of this rule too. Unlike the
complexity rules, coupling.cbo reads them at its own top level, where they
open the same flat form as threshold and set both dimensions at once. They
have always worked here; they are documented now because an option key that is
not declared is refused, and refusing a key that works would be wrong.
Framework CBO distinction¶
By default, CBO counts all dependencies equally: importing 50 PhpParser\Node\* types counts the same as depending on 50 application services. But framework coupling is structural (can't be eliminated without changing framework), while application coupling is architectural (should be minimized).
Qualimetrix provides two supplementary metrics to distinguish them:
| Metric | Formula | Purpose |
|---|---|---|
coupling.cbo-app |
Ca_app + Ce_app (framework deps excluded) | Application-only coupling |
coupling.ce-framework |
Count of efferent framework dependencies | Informational |
Configuration:
# qmx.yaml
coupling:
framework-namespaces:
- subtree: Symfony
- subtree: PhpParser
- subtree: Psr
- subtree: Amp
These are explicit namespace selectors. subtree: Psr matches Psr\Log\LoggerInterface but not PsrExtended\Custom; exact and full-subject regex are also available.
Using with the CBO rule:
Set scope: application to make the CBO rule check coupling.cbo-app instead of coupling.cbo:
rules:
coupling.cbo:
scope: application
class:
warning: 10 # tighter thresholds make sense for app-only coupling
error: 15
When no framework-namespaces are configured, coupling.cbo-app equals coupling.cbo (no effect).
Unmatched framework namespace¶
Rule ID: coupling.unmatched-framework-namespace
Severity: warning (fixed)
What it measures¶
A framework-namespaces selector that no name in the run matches.
The selector was written to move classes out of coupling.cbo-app and into
coupling.ce-framework. One that matches nothing moves neither, so every
application-scope coupling verdict is drawn from a wider set than you asked
for. Nothing else in the run says so: a missed prefix produces exactly the
report a run with no selector at all produces.
The framework namespace "Symfony\Bundle" matched no class the run analysed or
depends on. Nothing was moved out of the application scope for it, so
"coupling.cbo-app" still counts every class it was written to exclude and
"coupling.ce-framework" counts none of them.
The usual causes are a renamed vendor namespace, a leading backslash
(\Symfony never matches — write the namespace as it appears in an import),
and a dependency dropped in a refactor while its prefix stayed in qmx.yaml.
One finding per unbound selector, reported on the project. A selector is checked against both ends of every dependency edge — the targets, where external framework classes live, and the sources — because that is the set the metrics themselves classify.
When it stays silent¶
-
Declaration evidence is incomplete. Missing PHP, authored PHP removal, generated exclusions or an uncertain universe withhold namespace-absence claims; a
coveredreport state alone does not authorize them. -
No
framework-namespacesconfigured. Nothing was claimed, so nothing failed. - The selector matched. Including a selector that only matches your own analysed classes: that still moves them out of the application scope, which is what the option does.
- The run's dependency graph is empty. Check one file that depends on
nothing and there was nothing to classify at all, so a correct
project-wide
qmx.yamlis not reported for that invocation. - No dependency graph was built. There is then no set of names to ask about, and the run says nothing rather than accusing every prefix at once.
Configuration¶
It is an ordinary rule finding rather than a configuration error: it answers to
fail_on, --disable-rule=coupling.unmatched-framework-namespace,
@qmx-ignore coupling.unmatched-framework-namespace and the baseline. A shared
qmx.yaml that legitimately names a framework one repository does not use is
the case that keeps it out of the refusal path.
The message ends by naming the configuration that wrote the selector list
(Written by configuration file "…" (imported by configuration file "qmx.yaml").).
A list from a shared base, installed or not, is judged: writing
coupling.framework_namespaces in your own file replaces the base's list whole.
RFC -- Response for a Class¶
Metrics: coupling.rfc, coupling.rfc-own, coupling.rfc-external
RFC measures the size of a class's response set, using Chidamber and Kemerer's union of its methods and the targets directly invoked by those methods:
M contains every declared method, including abstract methods, plus the
methods effectively imported from traits. A trait alias adds its exposed name;
a visibility-only adaptation does not add another method. Class methods
override trait methods, and trait conflicts follow insteadof. This applies to
named classes, traits, interfaces and enums. Every enum also contributes its
implicit cases() method; a backed enum contributes from() and tryFrom()
as well. Methods inherited from a parent
do not enter M; a direct call to an inherited or parent method enters the
response set. Calls made inside a method's closure are included. Nested
anonymous-class methods and expressions outside methods are excluded.
Unread traits contribute no guessed methods to M; unresolved invoked targets
use the fallback described below.
coupling.rfc-ownis the size ofM.coupling.rfc-externalcounts distinct directly invoked responses outsideM, including directly called inherited methods. “External” here means outside the measured declaration's effective method set, not outside the analysed project.coupling.rfcis their sum. Repeated calls and calls to an already owned method do not increase it.
A statically known class response uses the resolved class name and method
name, compared without regard to letter case. Aliases, fully qualified names
and namespace-relative spellings of the same target count once. An imported
trait alias refers to its original method body when deciding whether a called
response is already owned. Responses in effective imported trait bodies use
the consuming class's this/self/static/parent context.
A named target outside the collected class roster retains its resolved class
and method identity. A known receiver whose readable hierarchy has no
matching method retains that receiver and method identity. An inherited target reached through unread ancestry
cannot identify its declaring body and uses the method-name fallback.
Which calls contribute¶
| Form | Response identity |
|---|---|
Own method via $this, self or static |
Already in M |
Inherited or parent method |
Resolved declaring class and method, where readable |
| Imported trait method or alias | Effective exposed method in M; invoked body resolved to its source |
| Typed non-variadic parameter, property or promoted-property receiver | Resolved class and method, when the declared type identifies one class |
Assignment of a known receiver to a local variable; direct positive instanceof branch |
The retained class and method, within that supported local scope |
Named static call; new ClassName() |
Resolved class and method; construction uses __construct |
| Nullsafe call | The same target as the ordinary method call |
| Chained return, array element, variadic parameter or untyped receiver | Method-name fallback, shared by repeated calls to that name |
Variable-class static call ($class::method()) |
Resolved class and method for a known object receiver; otherwise method-name fallback |
| Global function | A separate function response, independent of method responses |
| First-class callable or dynamic method name | Excluded |
The fallback also applies when ancestry or a trait is unreadable, or method lookup is ambiguous. Declared types provide static evidence; RFC does not follow runtime dispatch, infer return types or perform general flow or PHPDoc type analysis. Distinct unknown receivers calling the same method name can therefore share one response. These are metric values, with no separate RFC rule or configurable finding threshold.
Deviation from original spec
- Global functions contribute responses, and PHP 8.4 property hooks contribute
to
M; the original class metric has no corresponding PHP constructs. Hook property names remain case-sensitive. Imported hooks with conflicting property sources are omitted; PHP property compatibility is not validated. - Trait methods and adaptations use PHP composition rules, including nested
traits, aliases, visibility changes and
insteadof. - First-class callables are not invocations; dynamic method names are not resolved and do not contribute a response.
- An unresolved receiver, unread trait or ancestor outside the analysed paths falls back to the method name. The result does not claim complete runtime dispatch resolution.
Example¶
class InvoiceService
{
public function issue(Gateway $gateway): void
{
$gateway->send();
$gateway->send();
}
}
// rfc-own: 1 (issue); rfc-external: 1 (Gateway::send); rfc: 2
A larger response set means more behaviour to understand and test. Split responsibilities when one class reaches too many unrelated operations.
Instability¶
Rule ID: coupling.instability
Judged metric: coupling.instability on a class; coupling.instability-own on a namespace
What it measures¶
Instability measures the direction of dependencies for a class or namespace. It answers the question: "Does this class mostly depend on others, or do others mostly depend on it?"
The formula is:
Where:
- Ca (Afferent coupling) = how many other classes depend on THIS class (incoming)
- Ce (Efferent coupling) = how many classes THIS class depends on (outgoing)
The result is a number between 0.0 and 1.0:
- I = 0.0 (maximally stable) -- many classes depend on this one, but it depends on nothing. Like a foundation: you cannot move it without breaking everything above. Stable classes should be abstract (interfaces, abstract classes) so they are safe to depend on.
- I = 1.0 (maximally unstable) -- this class depends on many others, but nobody depends on it. Like a leaf on a tree: easy to change without affecting anything. Concrete implementations should typically be unstable.
Why is high instability bad?
An instability close to 1.0 means the class has many outgoing dependencies and nobody depends on it. While this sounds "free to change," it also means the class is very sensitive to changes in its dependencies. If any of those dependencies change, this class might break.
How to read the value:
| I (Instability) | Interpretation |
|---|---|
| 0.0 | Maximally stable (only depended upon) -- or not connected at all: with Ca = Ce = 0 the ratio is undefined and reported as 0 by convention |
| 0.0--0.3 | Stable -- hard to change, many dependents |
| 0.3--0.7 | Balanced |
| 0.7--1.0 | Unstable -- easy to change |
| 1.0 | Maximally unstable (only depends on others) |
Thresholds¶
Class level (enabled by default):
| Level | Threshold | Severity |
|---|---|---|
| Warning | >= 0.8 | Warning |
| Error | >= 0.95 | Error |
Namespace level (enabled by default, requires at least min_type_count (default 3) types declared in the namespace itself):
| Level | Threshold | Severity |
|---|---|---|
| Warning | >= 0.8 | Warning |
| Error | >= 0.95 | Error |
Example¶
// Highly unstable class (I ≈ 1.0): depends on 5 things, nobody depends on it
class DailyReportJob
{
public function __construct(
private UserRepository $users,
private ReportGenerator $generator,
private EmailService $email,
private Logger $logger,
private Clock $clock,
) {}
// Ca = 0 (nothing depends on this)
// Ce = 5 (depends on 5 classes)
// I = 5 / (0 + 5) = 1.0 -> ERROR
}
How to fix¶
- Reduce outgoing dependencies. Fewer
usestatements and constructor parameters mean lower Ce. - Introduce abstractions. If the class is a service that others could benefit from, extract an interface. This increases Ca (other classes will depend on the interface) and reduces I.
- Accept instability when it makes sense. Entry points like console commands, controllers, and cron jobs are naturally unstable (high Ce, low Ca). Consider disabling the rule for these or raising the threshold.
Deviation from original spec
Robert C. Martin (1994) originally defined Instability only at the package (namespace) level. Qualimetrix extends it to the class level for finer-grained analysis; the class-level metric is a Qualimetrix extension.
A namespace is not a package in Martin's sense once it has sub-namespaces, so Qualimetrix publishes two values and judges one. The rule judges coupling.instability-own, taken over the types declared directly in the namespace (Ca-own and Ce-own), the scope CBO and distance judge too. The subtree value coupling.instability folds in every outward dependency of the sub-namespaces while few dependents reach a parent that declares nothing itself, so the root of a bounded context reads close to 1 by construction. It stays published as aggregate evidence and feeds subtree distance, health.coupling and the HTML report's Martin diagram, but is not judged.
A namespace declaring no analysed type of its own publishes no coupling.instability-own and is never judged. One declaring only bare enums publishes the key but is not judged either: the judged population counts classes, traits, interfaces and implementing enums. A namespace that declares a type but has no own edge (Ca-own = Ce-own = 0) carries 0 by the convention above.
Configuration¶
# qmx.yaml
rules:
coupling.instability:
suppress_namespaces:
- subtree: App\Core\ValueObject
class:
max_warning: 0.9
max_error: 1.0
min_afferent: 1 # default: 1
namespace:
min_type_count: 5
min_afferent: 1 # default: 1
min_afferent -- minimum afferent coupling (Ca) required for a class or namespace to be checked; for a namespace it is the own afferent coupling, coupling.ca-own. Default: 1 (symbols with Ca = 0 are skipped). Set to 0 to check all symbols, or to 2 to also skip symbols with only one dependent. Symbols with very few dependents have high instability by definition, which is architecturally expected for concrete implementation classes.
namespace.min_type_count -- minimum number of types a namespace declares directly: classes, traits, interfaces and enums that implement an interface, as for Distance. Default: 3 (equality is admitted); 0 disables the floor, but a namespace is judged only if it declares at least one counted type, whatever the floor -- the same condition under which coupling.distance-own exists. Instability and distance are both package metrics of Martin's (D = |A + I - 1|) and judge one population. The earlier min_class_count counted classes of the whole subtree and is refused by name -- in YAML, as min-class-count and minClassCount, and in --rule-opt -- with exit code 3; rename the key and mind the new count.
For a simple pass/fail threshold on a single level:
bin/qmx check src/ --rule-opt="coupling.instability:class.max_warning=0.9"
bin/qmx check src/ --rule-opt="coupling.instability:class.max_error=1.0"
bin/qmx check src/ --rule-opt="coupling.instability:class.min_afferent=0"
bin/qmx check src/ --rule-opt="coupling.instability:namespace.min_type_count=5"
bin/qmx check src/ --rule-opt="coupling.instability:namespace.min_afferent=2"
Flat threshold shorthand (both levels at once): a bare threshold at the
rule's own top level -- not nested under class:/namespace: -- sets
max_warning/max_error to the same value on both the class and
namespace dimensions in one go, since their defaults already match (0.8/0.95).
Shorthand expands before configuration layers merge
As with coupling.cbo, a top-level threshold expands to both class and
namespace bands in its own layer. Overlapping shorthand and explicit band
writes in one layer refuse with exit 3. In a later layer, a nested write
replaces only the addressed leaves; for example,
--rule-opt="coupling.instability:class.threshold=0.8" overrides a file's
flat threshold: 0.9 for the class band while retaining namespace 0.9/0.9.
Top-level max_warning: / max_error: are options of this rule too. Like
coupling.cbo's warning/error, they open the flat form at the rule's own
top level and set both dimensions at once. They have always worked here and are
documented now for the same reason.
Distance from Main Sequence¶
Rule ID: coupling.distance
Judged metric: coupling.distance-own
What it measures¶
This rule checks the balance between abstractness and stability of a namespace (group of classes). It is based on the idea that:
- Stable packages should be abstract. If many classes depend on your package, it should consist of interfaces and abstract classes. That way, when you need to change behavior, you add a new implementation rather than modifying what others depend on.
- Unstable packages should be concrete. If your package depends on many others but nothing depends on it, it should contain concrete implementations. There is no point making it abstract if nobody is going to implement the abstractions.
The formula is:
Where:
- A (Abstractness) =
(abstract classes + interfaces) / (classes + traits + interfaces + enums implementing an interface)in the namespace (0.0 = all concrete, 1.0 = all abstract) - I (Instability) = the instability metric described above
The result is a number between 0.0 and 1.0:
- D = 0.0 -- the namespace sits on the "main sequence," meaning it has a balanced mix of abstractness and stability.
- D = 1.0 -- the namespace is far from the ideal balance.
There are two bad zones:
- Zone of Pain (bottom-left): stable + concrete. Many classes depend on this package, but it is all concrete code. Any change will ripple outward. Solution: add interfaces.
- Zone of Uselessness (top-right): unstable + abstract. The package is full of abstractions that nobody implements. Dead weight.
How to read the value:
| Distance | Interpretation |
|---|---|
| 0.0--0.1 | On the main sequence -- well balanced |
| 0.1--0.3 | Acceptable balance |
| 0.3+ | Off balance -- zone of pain or uselessness |
Deviation from original spec
Robert C. Martin (1994) defined Abstractness over abstract classes and concrete
classes only, so every PHP construct invented later has to be mapped onto that
model. Qualimetrix treats a bare enum as neutral and leaves it out of the
denominator entirely, rather than counting it as concrete: an enumeration of
literals offers no substitution point, since it cannot be extended, subtyped or
implemented. An enum X implements Y counts as concrete and stays in the
denominator -- it is a concrete implementation of a declared contract, and
without this exception a namespace holding one interface and N enums
implementing it would report A = 1.0 while its implementations sit right
beside it. Only explicit implements clauses count; the UnitEnum /
BackedEnum interfaces that every enum satisfies implicitly do not. The shape
of the formula is unchanged -- this is a scope adaptation for PHP, not a
different metric. A namespace whose only declarations are bare enums has an
empty denominator and publishes no abstractness or distance. An absent
value is not a measured concrete A = 0.0.
Two scopes on a namespace, and the project number¶
A namespace that both declares classes of its own and contains sub-namespaces has two coupling scopes, and both are published:
| Key | Scope |
|---|---|
coupling.ca / coupling.ce / coupling.instability / coupling.abstractness / coupling.distance |
The whole subtree rooted at this namespace; retained as aggregate evidence |
coupling.ca-own / coupling.ce-own / coupling.instability-own / coupling.abstractness-own / coupling.distance-own |
Only the types declared directly in this namespace, leaving the ones its sub-namespaces hold to those namespaces |
coupling.cbo and coupling.cbo-own split the same way, and there the rule
also judges the own scope: see CBO.
Instability is judged on the own scope too (coupling.instability-own): see
Instability.
When the measured populations and required inputs coincide, both scopes
have the same value. The absence of sub-namespaces alone does not establish
that their inputs are applicable. A namespace that
declares no type of its own carries no own-scope abstractness at all and gets no
-own key for abstractness, instability or distance (coupling.cbo-own is absent
there too): how far a package sits from the main
sequence is undefined where there is no package, and silence says that where
0.0 would not.
Publication and judgement follow different populations. coupling.instability-own
and coupling.cbo-own are published on a namespace declaring any analysed type;
distance and the instability judgement count only classes, traits, interfaces
and enums that implement an interface. A namespace declaring only bare enums
therefore carries both -own keys but is no package for distance or instability,
even with min_type_count: 0.
The project number is folded over own scopes. coupling.distance-own.avg is
the eligible-type-weighted mean of measured coupling.distance-own across
own namespaces. Each weight counts its own classes, traits, interfaces and
explicitly implementing enums; bare enums are excluded. A namespace without
measured own distance contributes no sample, and .count records the number
of measured namespaces — the project-level key is coupling.distance-own.avg, and
coupling.distance.avg does not exist. Folding the subtree rollups instead
would count a parent's declarations once for the parent and again for each
child. The older fold avoided that double count by taking leaf namespaces only,
which instead dropped every parent namespace that declares classes of its own: in
a tree where intermediate namespaces hold real code, a large share of the classes
were represented in the project average by no value at all.
The population the fold runs over is also what the health coverage line is a share of; see What a Score Covers.
Thresholds¶
| Level | Threshold | Severity |
|---|---|---|
| Warning | >= 0.3 | Warning |
| Error | >= 0.5 | Error |
The rule judges coupling.distance-own; its message uses own abstractness, instability and coupling.
Only namespaces with at least 3 own types are analyzed (configurable via minTypeCount): classes, traits, interfaces and enums with an explicit implements clause. Bare enums do not count.
A namespace with no dependency in either direction (Ca-own = Ce-own = 0) is not judged either. Its instability is 0 by convention, not by measurement, so a concrete namespace that nothing uses and that uses nothing would read D = 1.0 and be reported as a zone of pain -- which needs many dependents it does not have. The distance is absent and does not enter project or health aggregation.
A namespace that lacks an input publishes no distance at all rather than one computed from 0. A namespace that declares only functions is not in the class dependency graph, so it has no instability and gets no coupling.distance.
Example¶
Consider a namespace App\Payment with 10 classes:
- 0 interfaces or abstract classes (A = 0.0)
- Many incoming dependencies from other modules (I = 0.2, quite stable)
- D = |0.0 + 0.2 - 1| = 0.8 -- far from the main sequence (Error)
This namespace is in the Zone of Pain: it is stable (hard to change without breaking others) but has no abstractions (every change requires modifying concrete code).
How to fix¶
- For packages in the Zone of Pain: Add interfaces. Extract contracts that other modules can depend on, while keeping implementations as details.
- For packages in the Zone of Uselessness: Remove unused abstractions or make them concrete. Abstract classes that nobody extends are overhead.
- Aim for the main sequence: Stable packages should be abstract; unstable packages should be concrete.
Configuration¶
# qmx.yaml
rules:
coupling.distance:
max_distance_warning: 0.4
max_distance_error: 0.6
min_type_count: 5
include_namespaces:
- subtree: App\Domain
- subtree: App\Infrastructure
suppress_namespaces:
- subtree: App\Tests
For a simple pass/fail threshold:
bin/qmx check src/ --rule-opt="coupling.distance:max_distance_warning=0.4"
bin/qmx check src/ --rule-opt="coupling.distance:max_distance_error=0.6"
bin/qmx check src/ --rule-opt="coupling.distance:min_type_count=5"
min_type_count counts classes, traits, interfaces and explicitly implementing
enums declared directly in the namespace. Default 3 admits equality; 0
disables this floor. Missing count inputs contribute zero. The rule still
requires published own distance; scopes without coupling publish none. Selected population
accounting distinguishes these exclusions from healthy judgements.
min_class_count, min-class-count and minClassCount are refused for Distance
and for namespace Instability, which counts the same population under the same
min_type_count; CBO retains its class-count option over own classes.
By default, project namespaces are auto-detected from composer.json (autoload.psr-4).
include_namespaces is a list of explicit exact, subtree, or regex
namespace selectors. A bare scalar is refused. The generic --rule-opt door
uses the same kind:value scalar form, so
--rule-opt="coupling.distance:include_namespaces=subtree:App\Domain" selects
that subtree; several selectors are written as a list in qmx.yaml.
ClassRank¶
Rule ID: coupling.class-rank
Judged metric: coupling.class-rank-share
What it measures¶
The raw coupling.class-rank remains PageRank probability mass r. The rule
uses the separate share r / (1 / N) = r * N: how many times the vertex's rank
exceeds the uniform rank in this measured graph. A share of 1 is uniform.
N counts distinct measured logical class-like vertices, including isolated
ones with logical subjects in the metric repository. Unmeasured external
vertices stay outside this analysed PageRank population; duplicate declarations
do not inflate it. Both metrics project to each
exact declaration and provide namespace/project max, average and p95 values.
The rule judges exact PHP classes, including abstract classes, with positive
coupling.ca; interfaces, traits and enums retain evidence but are not judged.
Every remedy this rule offers — extract an interface, split the class, depend on
an abstraction — acts on a concrete class. A central interface is what those
remedies produce, so judging it would flag the cure. A central enum is a shared
closed vocabulary with no collaborators to split and nothing to hide behind an
interface. A trait is copied into its users rather than called, so its rank does
not describe a runtime hub. Their rank and share stay in --format=metrics, and
the JSON report counts them under abstentions with the gate php-class.
Thresholds¶
| Level | Share | Severity |
|---|---|---|
| Warning | >= 5 times uniform | Warning |
| Error | >= 10 times uniform | Error |
Equality reaches the boundary. There is no square-root scaling. Overrides and configuration use share units. Messages expand from two to six decimal places to distinguish unequal values; remaining display collisions say they are rounded.
Example¶
// This class is used (directly or indirectly) by most of the project
class DatabaseConnection
{
public function query(string $sql, array $params = []): Result { /* ... */ }
public function beginTransaction(): void { /* ... */ }
public function commit(): void { /* ... */ }
public function rollback(): void { /* ... */ }
}
// UserRepository depends on DatabaseConnection -> votes for it
// OrderRepository depends on DatabaseConnection -> votes for it
// PaymentService depends on DatabaseConnection -> votes for it
// ReportGenerator depends on DatabaseConnection -> votes for it
// AuditLogger depends on DatabaseConnection -> votes for it
//
// If these dependents are themselves important (high rank),
// DatabaseConnection's ClassRank grows even further.
// ClassRank share = 12 -> ERROR (12 times the uniform rank)
How to fix¶
- Extract an interface. Create
DatabaseConnectionInterfaceand depend on that. This applies the Dependency Inversion Principle: high-level modules depend on abstractions, not concrete classes. - Split god-class responsibilities. If the class does too many things, break it apart. For example, split
DatabaseConnectionintoQueryExecutor,TransactionManager, etc. - Reduce transitive importance. If the dependents of this class are themselves hubs, refactoring them to depend on abstractions will lower the ClassRank of this class too.
Implementation notes¶
PageRank keeps damping 0.85, at most 100 iterations and convergence epsilon
1e-6. Dangling vertices redistribute their rank evenly. Raw ranks sum to 1;
shares sum to N before projection to duplicate declarations. An empty graph
publishes neither metric, a singleton publishes raw 1 and share 1. Share values
are relative to the current graph population; narrowed and whole runs can differ.
Prioritization consumes share, uses its measured median for an unranked finding,
and publishes coupling.class-rank-share in JSON top issues. It never substitutes raw
probability for missing share.
Deviation from original spec
Namespace and project ClassRank aggregates sample physical class declarations. Each declaration repeats its logical node's rank and share, so sums, counts and averages are declaration-weighted. Raw PageRank sums to 1 over logical nodes before this projection, not over declarations.
The recommendation counts direct dependents (Ca). PageRank also reflects
transitive dependency paths, so one direct dependent that is itself a hub can
produce a high share. The report does not count transitive dependents.
Distance and Instability use the same expanding precision and explicit
display rounded marker as ClassRank when a value and threshold would otherwise
look equal.
Configuration¶
bin/qmx check src/ --rule-opt="coupling.class-rank:warning=6"
bin/qmx check src/ --rule-opt="coupling.class-rank:error=12"
Migrate old raw/scaled thresholds and baseline entries to the share metric; there is no raw-key fallback or option alias. See default thresholds.
Independent layer bands¶
CBO and instability spread a top band to class and namespace in its writing layer. Explicit writes to the same expanded leaves conflict in that layer; independent level settings such as enabled are preserved. Configure distinct level bands explicitly or put overlapping overrides in another layer. Effective-band validation includes written and default halves. These forms do not change metric direction, formulas or numeric defaults. A written enabled:true intentionally overrides a lower disable. See Configuration.
Dependency position and type shape¶
An edge type names the reference position: type_hint for parameter/return,
property_type for a property or promoted parameter, and constant_type for a
typed constant. Union/intersection/DNF are not separate relation kinds.
Graph JSON always publishes shape as an object mapping each type position
to its sorted observed shape names (single, nullable, union,
intersection, dnf), for example {"property_type": ["nullable"],
"type_hint": ["union"]}. An edge with no type-shape facts has shape: {}. Declaration facts (kind, parents/interfaces/traits,
attribute sites and __toString) travel separately from edges. Implicit
Stringable does not invent dependency edges or increase coupling. Logical PHP
class identity is ASCII-folded; exact source declarations remain separate.