Changelog¶
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased¶
0.27.0 - 2026-09-18¶
Breaking¶
Health scores move on every project. The six health.* dimensions were
recalibrated against a corpus that now includes legacy anchors, and the project
aggregate of a namespace-collected metric now includes the global namespace.
Scores fall for most projects — across a seventeen-project corpus the median
overall went from 79.1 to 73.4 — so a health.* finding can cross a warning or
error threshold that it did not cross before, changing a run's exit code.
Regenerate any recorded baseline. ADR 0062, "Health Scores Measure What They
Cover", records what moved and why, including what was decided against.
A filter value that used to be discarded in silence now exits 3. Each of
these accepted anything, dropped what it could not use and reported success;
each now refuses and says what did not bind. --rule-opt (a pair written
without =VALUE, an unregistered rule name, or an option the rule does not
accept — previously the pair was dropped); --log-level (a level outside
debug/info/warning/error — previously it fell back to info);
--cache-dir (a path that cannot be created or written — previously the cache
switched itself off), on every command that resolves one and not only on
check; --namespace and --class on check, and --namespace on
graph:export (a value selecting nothing — previously an empty report or an
empty graph, indistinguishable from a clean subtree); --format-opt (a key no
formatter reads — previously accepted and ignored). graph:export
--exclude-namespace deliberately keeps its silence: a missed exclusion leaves
the picture whole.
An empty --rule-opt/short-alias value is now refused, instead of becoming
a one-element list holding the empty string.
--rule-opt='code-smell.boolean-argument:allowed-prefixes=' used to set
allowedPrefixes to [''] rather than the default seven prefixes, so
$isActive/$hasPermission were flagged as violations; the same shape of
bug made --rule-opt='coupling.distance:include-namespaces=' stop finding
anything at all, because [''] never matches a real namespace. It also
affected code-smell.error-suppression:allowed-functions,
cohesion.lcom:exclude-methods and that option's own short alias
(--lcom-exclude-methods=). An empty value written after = on either CLI
door is now refused, with a message naming the rule, the option and what
to write instead. Refusing rather than defaulting is what the documentation
promises for a command-line value, and it keeps the two doors honestly
different: YAML's ~ says "take the default", while a command line that
stops after = says nothing at all. Write the intended value, or drop the
--rule-opt entry entirely to get the default.
coupling.cbo refuses an unknown scope instead of silently measuring the
other one. scope: accepts all and application; anything else — a typo
like applicaton, or any other word — used to fall back to all without a
word, so the run measured the opposite of what the configuration asked for. It
now exits 3 naming both accepted spellings. Migration: fix the spelling; a
configuration that meant all can also just omit the key.
debug:layer-assignment refuses a class the run never analysed (exit 3)
instead of answering "(no layer)" — the same words it uses for a real class
that no layer matched.
A threshold key written ~ inside rules: now selects nothing, instead of
selecting the flat form of a hierarchical rule. The keys are threshold: on
complexity.ccn, complexity.cognitive and complexity.npath; threshold:,
warning: or error: on coupling.cbo; threshold:, max_warning: or
max_error: on coupling.instability. Writing one of them without a value used
to count as writing it, with two consequences, both gone:
- the level blocks beside it were discarded in silence.
warning: ~above aclass:block oncoupling.cboleft the rule completely unconfigured and exited 0. The blocks are read now. (A command line stopping after=used to reach the same reading; it is refused outright now — see below.) threshold: ~besidewarning:/error:was refused withCannot mix "threshold" with "warning"/"error", naming a mix of one written value with nothing. It is accepted now and the graduated mode applies. Two keys that each carry a value are still two modes and are still refused.
Migration, for a configuration that was relying on the old reading: write the
threshold's value where you wrote ~ to keep the flat form, or, where the point
was to silence the class level of a complexity rule, write
class: {enabled: false}, which says so.
An unrecognised rule option key now stops the run with exit 3, at every depth
it can be written at. It used to warn at the top level of a rule's options and
to be dropped in silence inside a level slot — so callable: {warnign: 1,
error: 2} applied the error half, lost the warning half, and said nothing.
The refusal names the key and lists the options allowed at that exact position;
inside a slot it adds that other levels of the same rule take different options.
The set it compares against is now declared by the class that reads the keys, so
a key that works is never called unknown and a key that does nothing is never
called valid. See
ADR 0049
for the full migration table.
Seven legacy option aliases are removed. Each had a declared spelling doing the same thing; written now, they are unknown keys and exit 3.
| removed | write instead |
|---|---|
complexity.ccn: {warning_threshold: N} |
complexity.ccn: {callable: {warning: N}} |
complexity.ccn: {error_threshold: N} |
complexity.ccn: {callable: {error: N}} |
complexity.cognitive: {warning_threshold: N} |
complexity.cognitive: {callable: {warning: N}} |
complexity.cognitive: {error_threshold: N} |
complexity.cognitive: {callable: {error: N}} |
complexity.npath: {warning_threshold: N} |
complexity.npath: {callable: {warning: N}} |
complexity.npath: {error_threshold: N} |
complexity.npath: {callable: {error: N}} |
coupling.distance: {project_namespaces: […]} |
coupling.distance: {include_namespaces: […]} |
On the three complexity rules the retired aliases opened the flat format, which
also switches the class level off; callable: leaves it at its defaults, so add
class: {enabled: false} if you were relying on that. include_namespaces is an
exact replacement.
Top-level warning: / error: on complexity.ccn, complexity.cognitive
and complexity.npath are refused instead of warned. They never applied a
threshold there — write them inside callable:. The same two keys keep working
at the top level of coupling.cbo, where they always did; they are now
documented rather than undeclared, as are max_warning / max_error on
coupling.instability.
A level slot written as false is refused instead of silently ignored.
callable: false looked like the universal rule off-switch and was not one;
write callable: {enabled: false}. An empty or omitted slot is unchanged and
still means "leave this level at its defaults".
A computed_metrics: entry now declares its own vocabulary too. An
unrecognised key inside a computed_metrics.<name> entry, or inside its
formulas: map, used to be dropped in silence — warnign: 60 never applied,
formulas: {clas: "..."} was never read. It now stops the run with exit 3,
naming the key and the nine (respectively three) accepted spellings. Run
bin/qmx check and fix what it refuses.
A computed_metrics: value of the wrong type now refuses instead of being
dropped or changing behaviour silently. formula, description must be a
string; inverted, enabled must be a boolean; levels must be a list of
level words; threshold, warning, error must be a number or null. Two
of these used to be worse than silent: warning/error written with a
non-numeric value (a typo, say) silently dropped the threshold instead of
keeping the default, changing which findings the metric produced and which
exit code the run ended with.
computed_metrics.<name> where <name> is not a map now refuses.
computed.x: 5 used to be accepted and ignored, as if the entry were never
written; a bare false gets a hint toward {enabled: false}, the actual
off-switch.
A configuration-input refusal now exits code 3 everywhere, never 1, 2 or
255 depending on which command answered it. baseline:*,
baseline:rename-channels's own --format and file checks, rules,
graph:export and debug:layer-assignment used to pick their own exit code
for bad input — 1 here, 2 there — and a crash while reading configuration
could still reach the outer handler uncaught and exit 255 with a raw PHP
trace on both streams. Every one of those routes now goes through the same
ladder and exits 3. Stdout stays empty for a human-readable format; under
one of the six JSON-document formats (json, sarif, gitlab, metrics,
health, suppressed) the refusal replaces the report there as a
{error, exit_code} envelope instead — see the next entry. A CI wrapper
that only checked exit code != 0 sees no change; one that branches on the
code should treat 3 as "fix the configuration or the input", 1 as "file a
bug", and 2 or 4 as "read the command's own report" (directives'
inert-directive and incomplete-run codes, unchanged).
A configuration refusal now goes to standard error, and -q no longer
hides it. baseline:* commands used to write their refusal to standard
output, the same channel as their report, at normal verbosity — a script
piping --format=json output could be handed the refusal instead of the
report on failure, and -q silenced the message entirely. It is now written
to standard error as <error>…</error> text, or — under one of the six
JSON-document formats above — to standard output as the {error, exit_code}
envelope, and at a verbosity -q does not suppress. baseline:rename-channels
--format=json also moves from its own {error} shape to the same
{error, exit_code} envelope every other JSON-document refusal in the tool
now uses.
--silent now behaves like -q, not like "no output at all": it also no
longer hides a refusal or an internal error. --silent used to leave
VERBOSITY_SILENT in place, which drops every write regardless of the
message's own verbosity — so a run that failed under --silent still
produced zero bytes on both streams, the same behaviour -q had before this
round. Application::configureIO() now demotes VERBOSITY_SILENT to
VERBOSITY_QUIET immediately after Symfony applies it, so --silent is
-q under a different spelling: the report stays suppressed, but the
message explaining why there is no report is not. A CI wrapper that relied
on --silent producing zero bytes on every exit code sees output on a
refusal or an internal error where it previously saw none.
A command-line parsing error now exits 3, not 1. An unknown option or an
unknown command — anything Symfony's own console layer rejects before a
command body runs — used to reach the outer handler uncaught and take its
exit code from that exception's own getCode(), which is 0 for this class,
giving 1. It is now caught by the same outermost ladder as every other
refusal and exits 3.
A crash reading configuration now exits 1, not 255, and no longer prints a
raw PHP trace to both streams. Before this round, a defect that escaped
every command-level catch reached Symfony's default uncaught-exception
handling and exited 255. The outermost ladder now catches everything and
assigns 1 to whatever is not one of the round's two refusal signals; a trace
is still available, but only at -v and above, and only on standard error.
Input that used to be accepted silently is now refused. --direction on
graph:export, --channel on baseline:explain, and --output/--format
on both graph:export and debug:layer-assignment used to fall through to a
default or produce no match on an unrecognised spelling; each now names the
accepted values and exits 3.
baseline:generate into an existing file without --force now exits 3 and
writes to standard error, not 1. The refusal message — regenerating
discards every acceptance the file records — is unchanged; only its exit code
and stream move to the round's shared shape.
Named debt: not every configuration-input refusal reaches exit 3 through
the new carrier yet. 178 throw sites still exit 3 through a named secondary
signal — a caught InvalidArgumentException with no
ConfigurationRefusal behind it — rather than through the carrier itself, as
measured at 1513bf67.
After this round the same measurement counts 147: thirty-one sites moved onto
the carrier, and the rest — src/Infrastructure/Rule and
src/Infrastructure/Git among them — stay on the fallback by decision and are
counted rather than converted. See
ADR 0051
for why the exit code no longer depends on which command caught the failure,
and why -q no longer hides which key or file was refused.
Every rule option now declares the shape its value may take, and a value of
another shape exits 3 instead of being coerced. Until now the form of a value
was decided by whichever cast or guard reached it first, and the only check in
the tree fired on a string whose key name contained one of thirteen
substrings (threshold, warning, error, min, max, …). Four shapes that
used to run now stop:
| Written | Was | Write instead |
|---|---|---|
warning: "15" — a quoted number where a whole number is declared |
accepted, cast to 15 |
warning: 15 |
warning: 10.5 — a fraction where a whole number is declared |
accepted, cast to a whole number | warning: 10 |
enabled: [7331], enabled: "false" |
accepted; a non-empty list or string is truthy, so the rule stayed on | enabled: false |
suppress_paths: 7331 — a scalar where a list is declared |
accepted and silently ignored: nothing was suppressed | suppress_paths: [src/Sub] |
exclude_methods: {a: getName}, allowed_prefixes: {a: is} — a map where a list is declared |
accepted and applied: the map's keys were discarded and its values used | exclude_methods: [getName], allowed_prefixes: [is] |
The refusal names the rule, the key, the level where there is one, the expected
form and the written one. Values written on the command line are unaffected:
they are text by construction and are converted before the shape is judged, so
--rule-opt="size.method-count:threshold=25" and
--rule-opt="complexity.ccn:enabled=false" work exactly as before. Only YAML
distinguishes 15 from "15". See
ADR 0055.
A configuration root written in the wrong container now refuses instead of
being dropped. A section written as a list (cache: [dir], parallel: [3],
coupling: [x]) used to crash the loader with Internal error: ... int given
and exit 1; a list root written as a map (only_rules: {a: complexity.ccn},
paths: {a: src/Domain}, exclude: {a: Sub}, disabled_rules: {a: ...}) used
to fall through to the default while the run reported success — so a document
that looked like it narrowed the run to one rule silently analysed everything.
Both now exit 3 and name the container expected: cache: {dir: …},
only_rules: [complexity.ccn]. suppress_paths and suppress_namespaces
written as a map used to exit 1 with Internal error: array_push() does not
accept unknown named parameters; they now refuse the same way.
format and cache.dir now refuse a value of the wrong type. format:
true and format: [json] were accepted and ignored — the run produced the
default summary report and said nothing; cache: {dir: true} and cache:
{dir: [probe-cache]} were dropped and the cache went to .qmx-cache as if the
key were never written. Each now exits 3 naming what it expected.
An empty string is refused on four command-line options. --fail-on=,
--format=, --cache-dir= and --memory-limit= used to be accepted and take
the default. Each now exits 3 and lists what it accepts. Omit the option to get
the default.
A threshold written in one configuration layer now survives the layer above
it, and that can change the severity of findings you already have. A
threshold: N is shorthand for both halves of a warning/error band. When a
higher layer rewrote only one half — a preset setting warning/error under a
qmx.yaml threshold, or either under a --rule-opt — the half the higher
layer did not rewrite used to fall back to the rule's compiled default instead
of keeping the value the shorthand meant. It now keeps it.
What to re-check after upgrading: any rule configured across two layers where
one of them writes threshold. Compare finding SEVERITIES, not counts. A
preset threshold: 25 under a qmx.yaml warning: 10 used to yield
warning 10 with the rule's compiled error (20 for the complexity rules) and
now yields warning 10, error 25, so findings scoring 20-24 move from error to
warning — and a run with --fail-on=error can change colour with no
configuration change.
Two narrower consequences of the same change. A layer that wrote threshold
and warning together was illegal already, and the old cross-layer cleanup hid
it whenever a higher layer wrote the other mode; it is now refused, as it is
when a single layer writes it. And a rule with no entry in the internal
threshold-group catalogue no longer has its grouping guessed from a key's
spelling — the guess is gone, so such a rule refuses a cross-layer mode change
rather than silently guessing at it.
A key written ~ no longer erases what a lower layer wrote. ~ means the
author left the value to what it would otherwise be, which is what it already
meant beside a populated alias. Across layers it used to mean something else:
qmx.yaml writing warning: ~ over a preset's warning: 2 dropped the 2 and
the rule fell to its compiled default, and threshold: ~ dropped the lower
layer's whole band. The same held one level up, where rules: {some.rule: ~}
erased everything a preset had configured for that rule. In every case the
lower layer's value now stands. An explicit false or true for a rule still
switches it off or on.
annotation.directive's unused_directive_severity, architecture.unassigned-class's
mode and architecture.layer-violation's severity now declare the words they
accept. The words themselves, and the fact that they ignore letter case, are
unchanged; ~ still selects the default. What changes is where a wrong value is
refused: at the option seam rather than inside the rule, so the message names the
accepted set in the seam's wording and arrives before the rule is built.
Changed¶
- The
tools/directory (PHPStan extensions and their tests) no longer ships in thecomposer create-project/dist package; it is development-only and is now excluded via.gitattributesexport-ignore, alongside the project's other dev-only trees. - Every health dimension now publishes what share of its subject it was
computed over.
--format=jsongains acoverageobject per dimension (measured count, eligible population, ratio, unit and the.countit came from) and--format=healthprints one line per dimension. The narrowest input decides, so a cohesion score computed from the third of classes that carry TCC now says so. Scores themselves are unchanged and are not damped by coverage. Where coverage is undefined —health.overall,health.typing, a class-level or namespace-filtered score — the field states that and why, rather than reporting zero. - Configuration that binds to nothing is now reported instead of passing
unnoticed, through six new channels, all
warningat project level:discovery.unmatched-exclude(an--excludevalue orexclude:entry that removed no directory),suppression.unmatched-path,suppression.unmatched-namespaceandsuppression.unmatched-rule-ledger(asuppress_paths/suppress_namespacesvalue, global or underrules.<name>—suppress_namespace_channelsincluded — that names no analysed file and no declared namespace),coupling.unmatched-framework-namespace(acoupling.frameworkNamespacesprefix under which no name fell) andarchitecture.unmatched-exclude(a layerexclude:clause that removed no class from a layer that did match something). Every one of the six is judged only on a run wide enough to judge it: paths covering everythingcomposer.jsondeclares as production code —psr-4andpsr-0roots,classmapandfilesentries alike. A narrower run, or a project whose manifest declares no production autoload at all (nocomposer.json, one that does not parse, or one without a production section), cannot tell a stale value from one whose subject lies outside the slice, and every channel stays silent there. Each configured value is then judged against the place it names, so an entry written fortests/is not reported byqmx check src/while a stale entry insidesrc/on the same run still is; a value that begins with a glob names no place and is never reported, on the namespace side as on the path side; a layerexclude:under a template is judged once across every layer the template expanded to. Each finding carries the value it is about in its identity, so accepting a stale value in a baseline accepts that value and not merely one more finding on the channel: replacing it with a different unbound value is reported rather than passing under the accepted entry. The suppression channels answer a different question from--format=suppressed, which reports suppressors that removed nothing — a state an honest, paid-down suppression also reaches; they are not written bybaseline:generate, so accepting one is a decision written by hand, and a shared configuration silences a single channel by name withdisabled_rules: ['suppression.unmatched-path']. -
The refusal for an unknown
health.<x>name now lists all six built-in dimensions (health.complexity,health.cohesion,health.coupling,health.typing,health.maintainability,health.overall) instead of five: the list used to be built from the five sub-dimensions and omittedhealth.overall, even thoughhealth.overallcan itself be overridden. The same refusal now fires identically whether the entry carried a formula orenabled: false— two different messages for the same typo are gone. -
Five CLI short aliases the product has always accepted are now documented in the options tables:
--wmc-exclude-data-classes,--lcom-exclude-methods,--data-class-exclude-exceptions,--property-count-warningand--property-count-error. Nothing about them changed; they simply were not listed anywhere, so there was no way to find them but to read the source. -
The documented form of four list-valued rule options is now stated rather than left to be discovered.
code-smell.boolean-argument.allowed_prefixes,code-smell.error-suppression.allowed_functions,cohesion.lcom.exclude_methodsandcoupling.distance.include_namespaceseach accept a single string as the one-element list, and a digit string is an element like any other. The same pages also drop two--rule-optexamples that could not work:allowed_prefixes=is,has,canwas never split on the commas — it set one prefix spelledis,has,can, which matches nothing. A CLI door carries one scalar; several values are written as a list inqmx.yaml. Migration: replace any--rule-optfor these four options that carries commas with the list form in the configuration file. -
A cache directory that is a name of digits, and a computed-metric formula that is a bare constant, are documented as what they always were —
cache: {dir: "7331"}is the directory7331, andformula: "80"is a metric worth 80 everywhere. Both need the quotes: unquoted they are numbers, and neither a path nor a formula is a number.
Fixed¶
-
The composer package no longer carries the HTML report's JavaScript sources, its eight vitest files, the lockfile, the vite config or
dev.html. A consumer receives the four assets the report is rendered from and nothing else — 33 entries under that tree became 6. -
The Docker image documented in the quick start now builds and runs. Both
docker build -t qmx .and thedocker run ... qmx check src/that follows it had been broken since the first release:composer.lockwas excluded from the build context while the Dockerfile copies it, and the autoloader's authoritative classmap was built before the source was in place, so an image that did build answeredClass "...ContainerFactory" not foundon every invocation. A CI job now builds the image and analyses a mounted tree with it. -
A health score's breakdown now lists the inputs that score was computed from, with the targets its own formula applies. The breakdown carried one input list per dimension for all three levels, so a project coupling score built from CBO aggregates was explained by namespace-shaped inputs, and the typing breakdown above class level was silently empty because the key it named does not exist there. Targets came from a base-key fallback and contradicted the formulas they described — the maintainability minimum advertised "above 65" where the formula stops penalising at 5. Inputs and targets are now resolved per level and checked against the formulas by a test.
-
The documented output-format schemas now match what the formatters emit. The published key lists for
json,metricsandsarifhad drifted from the product on surfaces consumers parse with scripts:summary.infoCount, thehealthentries'worstContributorsand the shape of theirdecompositionentries, the counters in themetricssummary, SARIF'stool.driver.informationUri, itsrules[]catalogue,invocations[]andoriginalUriBaseIds, and the object a non-nullacceptedLevelactually is ({shape, describe, count}, alongside the breach's promotion toerrorseverity) were all absent from the page. A new test runs every machine-readable formatter over fixtures and compares the key set of each schema node against both language versions of the page, so a description that stops being true now fails the build. -
An empty
cache.dirno longer offers--no-cacheas the way out. Both cache refusals used to end with "or disable the cache with --no-cache". Against an unwritable directory that is true and it stays — the flag really does carry the run past that check. Against an empty value it never was: the path is judged before the flag is consulted at all, so the run stopped whether or not the reader followed the advice. That refusal now names the default directory (.qmx-cache) instead. -
~now means one thing across the document's roots and sections. Writing a key and leaving it empty —key:or the explicit YAML nullkey: ~— takes the default for that key's own value. It used to depend on which branch of normalization a root fell into:cache: ~took the default, whilecoupling: ~,computed_metrics: ~,architecture: ~andexclude_health: ~refused. Two things are unchanged and are stated rather than implied: a~element of a list is still refused where the list holds non-empty strings, because an element is a value and not an unwritten key; and a~written as the value of a key is never itself a value the rule can act on. Insiderules:the equivalence now holds for the keys that choose how a rule is read as well — see the Breaking entry on threshold keys above. - A directory whose name is a bare number now works in
suppress_paths:. The root-level key refused it as "not a non-empty string"; it is converted now, the way--excludealready is.suppress_namespaces:still refuses one, because a namespace segment cannot begin with a digit. - A refusal about a container names its element in the plural. It read "a list of a non-empty string"; it reads "a list of non-empty strings".
- Every source of a value is judged, not only the winning one. A
format:orcache.dir:of the wrong shape in the configuration file ends the run even when a command-line flag would have overridden it: the file is part of the configuration whether or not its value survives the merge. --exclude=7no longer crashes the run. A directory whose name is a bare number arrived as an integer and reached code expecting a string; the value is now converted rather than refused, because such a directory name is lawful.- Three refusals now carry the
Configuration error:frame. A malformedcoupling:section —coupling: ~,coupling: {framework_namespaces: true}and the rest — used to answer with a barecoupling.framework_namespaces must be a list.on standard error, with no frame around it, so a caller distinguishing a configuration refusal from a crash by that frame could not see it. It was the only configuration root that answered without the frame. qmx.yaml.exampleno longer ships three examples that fail: the rule selectors underdisabled_rules:/only_rules:needed theX.*wildcard form, and the custom computed-metric example still used the retiredccn__avgvariable encoding.computed_metrics.<name>.levelswritten as a map (a value copied from therules:section, which does have a per-level block) used to crash instead of refusing, and the three commands that read configuration crashed differently:checkexited 1 with an "Unexpected error" and empty stdout, whiledirectivesanddebug:layer-assignmentexited 255 with a raw PHP trace on both streams. All three now refuse with exit 3 and a message naming the entry and the accepted level words.
0.26.0 - 2026-09-08¶
Breaking¶
Five channel codes, one metric key and one configuration key are renamed. The full table, and a machine-readable map, are in docs/migration/v0.26-rename-map.md.
| old | new |
|---|---|
architecture.coverage |
architecture.coverage-gap |
complexity.cyclomatic |
complexity.ccn |
design.inheritance |
design.dit |
duplication.code-duplication |
duplication.clone |
maintainability.index |
maintainability.mi |
design.type-coverage.pct |
design.type-coverage.all |
coverage: (in architecture:) |
coverage-gap: |
Migration is not one command. bin/qmx baseline:rename-channels carries one
of the four things that change, and it needs two steps even there. The other
three have no command, and one of them is silent:
- Your baseline file — a command, and two steps. An accepted entry on an old
channel stops suppressing: the finding it covered returns at its own severity.
The stale entry does not fail the run by itself — it prints
N baseline entries could not be applied— so CI can stay green while a suppression evaporates, or redden for a reason its own output never names. Bring the file to the current baseline version first (the carry substitutes a name and converts nothing, and refuses an older version), thenbin/qmx baseline:rename-channels qmx-baseline.json v0.26-rename-map.tsv. - Configuration and command line — loud, exit 3.
qmx.yaml(rules:,only_rules:, and thearchitecture:section'scoverage:key),--rule-opt=,--disable-rule=, and anycomputed_metricsformula readingm["design.type-coverage.pct"]. An old name is an unknown rule, option owner, key or metric: the run refuses and does not fall back to a default. Where the message lists the allowed keys, the new name is in the list. @qmx-ignore/@qmx-thresholdin your own code — the suppression is lost. A directive naming an old channel becomesannotation.unresolved-directive. You see it, but the finding it used to suppress comes back at the same time. Rename by hand; there is no command.- Anything that stored our output — silent. SARIF
ruleIdandrules[].name, GitLabcheck_nameandfingerprint, Checkstylesource, JSON and metrics field values, dashboard columns. The old spelling stops appearing, nothing fails, and your history splits in two unless you migrate the store.
Two published sentences move with the configuration key they name, so a tool
matching on message text will notice: the layer-coverage diagnostic now reads
Architecture coverage-gap: N edge(s) …, and its recommendation says leaving
coverage-gap on "ignore".
Severities, thresholds, subject keys and occurrence keys are unchanged. The
--cyclomatic-* CLI aliases keep their names.
0.25.0 - 2026-09-06¶
Changed¶
- The progress bar is drawn on standard error instead of standard output, so a
report on a terminal is no longer prefixed with terminal control bytes:
bin/qmx check src/ --format=json > report.jsonnow writes valid JSON without--no-progress. The bar is shown when standard error is a terminal, which it still is when standard output has been redirected. --no-progressis accepted by every command that shows progress, not bycheckalone:directives,debug:layer-assignment,baseline:generate,baseline:update,baseline:cleanupandbaseline:explaintake it too.graph:exportanalyses too but has never drawn a bar, so it does not take it.bin/qmx directivesreports what every inline@qmx-ignoreand@qmx-thresholdin the analysed tree actually does — effective, applied-boundary-only, inert, or unmeasured with a named reason — intextorjson, under the same--preset,--only-rule,--disable-ruleand--rule-optthe run being defended uses. It exits2on a proven inert directive and4when the run could not parse part of the tree.bin/qmx directivestakes--sweep=narrow|full(defaultnarrow): a@qmx-thresholdnames one rule, so by default only that rule is re-executed to judge it;--sweep=fullre-executes every enabled rule for the same verdicts. Both the text and--format=jsonreport carry the sweep the verdicts were measured under.- The
produced_findingscountbin/qmx directivesreports is the number of findings the rules produced. It no longer includesannotation.unused-directive, which a run assembles after rule execution: no directive may address that channel, so no verdict is measured against it. On a tree with stale directives the number is lower than before. composer checknow audits inline directives as part ofcheck:self: a proven inert directive fails the aggregate the same way a red gate does.bin/qmx rulesprints, under each rule, the catalogue metric its channels judge. Twenty-two of the fifty-two static channels say something; the rest publish a magnitude of their own making, or no magnitude at all, and stay silent. The rule pages carry the same pair under their rule id.- A
@qmx-thresholdnaming a metric key instead of a rule is answered with the channel that judges that metric and the rule to address. It used to be told that no declared name was close to it:complexity.ccnis eight edits fromcomplexity.cyclomatic, so a near-spelling search could never reach the answer. baseline:rename-channels <baseline> <map>carries an existing baseline onto renamed channel names along a declared tab-separated map, without running an analysis — use it instead of regenerating, which silently accepts whatever the tree has accumulated. Carrying an entry changes its selector (a selector is a digest of the identity, and the channel name is part of it), so a savedbaseline:cleanup --remove=<selector>stops addressing a carried entry; re-read selectors from a freshbaseline:cleanuplisting.
Fixed¶
- A
computed_metrics:formula that misspells a metric key now fails configuration validation by name, instead of silently reading it as absent. The check applies to a built-inhealth.*formula overridden in configuration exactly as it does to a user-definedcomputed.*one, and ignoreshealth.*/computed.*cross-references between computed metrics, which a separate check already validates. - A mistyped directive target is no longer answered with
annotation.unused-directive. The near-spelling search offered it to anyone who mistyped a neighbouringannotation.*name — it sits one edit from its own family — and following the advice produced a directive the next run refuses. Every branch of the answer now drops it: the near-spelling search, the channel list a rule name is answered with, and the answer to a group form. The full channel list of a rule, banned ones included, is whatqmx rulesis for. cohesion.lcom(LCOM4) no longer counts a method that returns another class's constant (OtherClass::BAR, including an enum case) as an isolated, stateful component. It already recognizedself::X/static::Xas reading no instance state; the same is now true for any literal class name, so replacing a magic value with a shared constant no longer inflates LCOM.--disable-ruleand--only-ruleact onannotation.unused-directive. Naming it in--disable-rulewas inert and said nothing, and an--only-rulenaming a sibling channel ofannotation.directivepublished it anyway. The channel is assembled after rule execution, which until now also meant assembled past the selection every other finding passes. The per-ruleexclude_paths/exclude_namespacesunder that rule are still inapplicable to it, as before.- The progress bar and detailed logging no longer destroy each other on a
terminal. Both write to standard error, and the bar erased upwards by the
height of its own section, so at
-vvand-vvva log line that arrived between two frames was wiped out and the bar itself froze at0%for the rest of the run. Every writer to that stream — the logger, warnings emitted during a run, the report notes,graph:export's incomplete-analysis report and an uncaught error's trace — now goes through one owner, which erases the frame, writes the line permanently and redraws the frame beneath it. - An
exclude_namespace_channelskey can name a channel whose name contains a hyphen. The keys of that map were case-normalized along with the typed option keys around them, socode-smell.boolean-argumentreached the run ascodeSmell.booleanArgumentand ended it with exit code 3 — printing the correct name in the same sentence that refused the written one. Every form of the key was affected: the exact name, theX.*group, thechannel:namespacepair, and every computed metric, whose names the name validator requires to be kebab. Keys without a hyphen are unaffected, and nothing else about the option changes: a key still has to name a channel its rule produces, and one naming a channel that never reports a namespace aggregate is still accepted and still excludes nothing. bin/qmx directivesno longer demands the removal of a live directive whose rule the configuration switched off per level. A directive bound to a declaration —@qmx-threshold, and@qmx-ignorein a docblock — now reportsunmeasuredand exit0when the rule is off at the level it sits on, as a rule disabled through a plainenabled: falsealready did, instead ofinertand exit2. The two physical forms,@qmx-ignore-fileand@qmx-ignore-next-line, carry no declaration and are still answered at producer granularity: a rule off at only one of its levels still reports theminert.
Breaking¶
LoggerFactoryInterface::create()takes the run's already-resolved diagnostic writer, not the console output. The parameter type is unchanged (Symfony\Component\Console\Output\OutputInterface) and the rename$output→$diagnosticsdoes not stop old code compiling, but the factory no longer callsgetErrorOutput()on what it is given: a caller that passed a fullConsoleOutputInterfaceexpecting the factory to pick standard error now gets its log on standard output. Pass the writer the error stream's owner hands out instead —$errorStream->writer($output), fromQualimetrix\Infrastructure\Console\ErrorStream— which is whatRuntimeLoggerConfiguratordoes. The stream has one owner now, and choosing one here was the second opinion that put log lines inside the progress frame.- The console classes that write diagnostics take that owner as a required
constructor argument:
Application,ResultPresenter,ProfilePresenter,RuntimeLoggerConfigurator,FindingFilterOrchestratorandGraphExportCommandno longer default it to anErrorStreamof their own. A composition that omitted it used to get a private owner, drawing its frame around a section list nobody else shares — the two-owner defect, reintroduced by omission. Two of them also take it earlier in the signature, before their optional collaborators:ProfilePresenter($report, $errorStream, $renderer)andGraphExportCommand($analyzer, $projection, $errorStream, $logger). Code composing these by hand should pass the one instance the container holds ($container->get(ErrorStream::class)), asbin/qmxdoes. @qmx-ignore,@qmx-ignore-fileand@qmx-ignore-next-lineaddressingduplication.code-duplication— the exact name,:project, or a group likeduplication.*that reaches it — are now refused withannotation.unresolved-directiveat the line they are written on, instead of the previously accepted forms that silently did nothing everywhere but the one copy the duplicate scan happened to visit first. The channel reports one finding per duplicate block aggregated at project level: no declaration a symbol directive binds to is the project, and the file a file or next-line directive names is only the block's first occurrence, an implementation detail of the scan and not something an author controls. Disable the rule instead (disabled_rules: [duplication.code-duplication]/--disable-rule=duplication.code-duplication), or accept the occurrence in the baseline.
A bare directive naming no channel is affected too. A plain
@qmx-ignore / @qmx-ignore-file / @qmx-ignore-next-line with no channel
addresses nothing, so the ban above has nothing to refuse it for — but it
also no longer silences a duplication.code-duplication finding by covering
every channel, exactly as it stopped silencing annotation.unused-directive
below. A bare directive that silenced only a duplication.code-duplication
finding before this release now produces annotation.unused-directive
where it previously produced no finding at all.
- No inline directive can silence annotation.unused-directive any more — the
channel that reports which directives did nothing. Three separate things
change for a project that used it.
A directive naming the channel is now refused. @qmx-ignore,
@qmx-ignore-next-line and @qmx-ignore-file all fail the run with
annotation.unresolved-directive on the line the directive was written on,
whether the target is the exact name, annotation.*, or either with :file
after it. Twelve spellings in total.
The form with no rule filter stops silencing it — with no diagnostic.
A bare @qmx-ignore-file with no channel, or an explicit * target on
@qmx-ignore-file / @qmx-ignore-next-line, names nothing, so there is
nothing to refuse and nothing to report; findings it hid until now simply
appear in the report. There is no warning for this one, and there cannot be:
this entry is the only notice of it.
@qmx-ignore-file annotation.* no longer addresses the three
configuration-error channels. It used to be a legal way to address
annotation.unresolved-directive, annotation.unsupported-threshold and
annotation.invalid-threshold together, and was reported inert. It is now
refused whole, because its expansion reaches the banned channel. Naming any
of the three by its exact name is unchanged.
What to do instead, with what else each choice takes with it:
| Instead of the directive | What else it silences |
|---|---|
| Delete the directive it complains about | nothing |
| Accept the finding into a baseline | nothing; the channel stays ratchetable on purpose |
| Narrow the run with a git scope | nothing on this channel; the scope applies to the whole run as always |
Top-level exclude_paths |
the file leaves the analysis entirely, with every channel on it |
rules: { annotation.directive: false } |
the three configuration-error channels above go with it — annotation.unresolved-directive, annotation.unsupported-threshold, annotation.invalid-threshold — because the same rule's validator declares them |
Two exclusions do not work on this channel and never did: the top-level
exclude_namespaces (the finding's subject is the file the annotation sits
in, so it carries no namespace to match) and the rule's own exclude_paths /
exclude_namespaces (they close with rule execution, and this channel is
assembled after it).
A finding on the channel is otherwise unchanged: ordinary debt with a
configurable severity, inside every stage of the pipeline. Rationale and the
rejected alternatives are in ADR 0041,
docs/adr/0041-no-directive-may-silence-the-unused-directive-channel.md.
- Every class of the design.* rules moves under a subject segment of its own:
Qualimetrix\Analysis\Evidence\Design\DitGlobalCollector becomes
Qualimetrix\Analysis\Evidence\Design\Inheritance\DitGlobalCollector, and likewise
for the DataClass, GodClass and TypeCoverage subjects. Rule names, channel
names, metric keys and CLI options are unchanged; only class strings move.
- ThresholdAwareOptionsInterface requires warningBoundary(), returning the
class's warning threshold or NoConfiguredBoundary::MoreThanOneBoundary when
it holds several. baseline:explain asks for the number instead of guessing a
property name, and now resolves coupling.distance, design.god-class and
design.data-class, all three of which it previously reported as
"not resolvable".
- Every published metric key is renamed to family.metric in kebab: ccn →
complexity.ccn, classCount → size.class-count, typeCoverage.paramTotal
→ design.type-coverage.param.total, and so on for all 82. Aggregated
spellings follow their key (ccn.avg → complexity.ccn.avg). The keys appear
in --format=metrics, --format=json and the HTML report.
- Computed-metric formulas address a metric by its key through one variable:
m["complexity.ccn.avg"] replaces the ccn__avg encoding. A formula that
indexes m with anything but a quoted literal is refused.
- A computed metric's name must be lower-case kebab after health. or
computed.: computed.my_score becomes computed.my-score.
- The three type-coverage rules are renamed design.type-coverage.param,
design.type-coverage.return and design.type-coverage.property. Their CLI
options are unchanged (--param-type-coverage-warning and its siblings).
- Every rule name must be lower-case kebab in every segment; a malformed name
now fails container assembly instead of registering under a heading of its own.
- qmx rules --group=<name> fails and lists the existing groups when no rule
belongs to the group, instead of printing an empty listing and exiting 0.
- Suppression stops calling itself exclusion. Of the six mechanisms that used
to hide under the word exclude, four earned it — the finding is never
produced — and stay: root exclude, exclude_health,
architecture.<layer>.exclude, and the per-rule exclude_readonly /
exclude_promoted_only / exclude_data_classes / exclude_tests /
exclude_exceptions / exclude_methods family are unchanged. The other two
produce a finding and then throw it away, and are renamed: exclude_paths →
suppress_paths, exclude_namespaces → suppress_namespaces,
exclude_namespace_channels → suppress_namespace_channels, at both the
root of qmx.yaml and inside a rules: { <rule-name>: { ... } } block. The
matching CLI flags rename the same way: --exclude-path → --suppress-path,
--exclude-namespace → --suppress-namespace. SuppressionMechanism's four
path/namespace cases are renamed to match (PathExclusion →
PathSuppression, and its three siblings likewise), and their values gain
the -suppression suffix
(path-exclusion → path-suppression, namespace-exclusion →
namespace-suppression, rule-path-exclusion → rule-path-suppression,
rule-namespace-exclusion → rule-namespace-suppression), visible in
--format=suppressed. Nesting level (global vs. per-rule) stays what it
always was — a scope of application, not a second mechanism — so the two
mechanisms are still two, not merged into one. Each retired key and flag is
refused, not silently ignored: an unrecognized key inside a rule used to
only warn, so an unmigrated config would keep running while its suppressions
quietly stopped applying; the refusal message names both suppress_* (for
suppressing findings a rule still produces) and the root exclude (for
keeping a file out of the analysis entirely) so the two are not confused
again. Every door answers with the same sentence and exit code 3, echoing the
spelling that was actually typed — snake, kebab or camel, root key, rule
option or flag. graph:export --exclude-namespace is untouched — it narrows the
exported graph, not a set of findings — and neither is the root exclude:
block. Existing qmx-baseline.json files apply unchanged: no channel code
or subject moves.
Fixed¶
- An aggregated metric requirement (
size.class-count.sum) resolved its base key by cutting at the first dot, which matched no provider for any key whose name contains a dot. - The GitHub Action's structured formats (
json,sarif,gitlab,suppressed) redirected QMX's stderr into the same file as the JSON payload, so an ordinary product warning (e.g. a coverage notice) made the published artifact unparsable. stderr now goes to the Action log as a warning annotation instead. annotation.unused-directivejudged a suppression by the findings the report published rather than by the findings the rules produced, so a@qmx-ignorecovering a finding thatexclude_namespaces,exclude_namespace_channelsorexclude_pathswould have dropped anyway was reported as silencing nothing.
Changed¶
- Every rule now declares its own estimated remediation time (in minutes) on its own class, alongside its documentation page and default thresholds. See Remediation Time for the full table.
coupling.class-rankdebt is no longer scaled by overshoot: its rank is a project-wide normalised PageRank rescaled per class count, so a stored value is not comparable across runs — it now reports its flat base estimate like every otheroccurrence-shaped channel. - Baseline files are written one entry per line inside the same JSON document: a tightened ceiling is a one-line diff, and the file is two thirds its former size (60 401 B against 90 365 B for this project's own 264 entries). The layout is presentation only — the schema is unchanged at the time of this line-per-entry change, and a reformatted file still loads. (The schema itself changes separately; see the version 12 entry below.)
- Corrected computed-metric reference examples to use the registered
metricsoutput format. - Added universal per-rule
exclude_namespace_channelsconfiguration for suppressing selected namespace-aggregate violation channels without hiding class findings or sibling channels. architecture.coveragenow includes analysed classes outside every declared layer even when they have no dependency edges, socoverage: errorcan enforce complete project ownership instead of checking only graph endpoints.- Rule and channel selectors now support an explicit
X.*wildcard for "strictly the descendants ofX", available everywhere a name is written:only_rules,disabled_rules,--only-rule,--disable-rule, per-ruleexclude_namespace_channels, and the@qmx-ignorefamily. - New rule
annotation.directivereports inline@qmx-*directives that address nothing, can never apply, or no longer do anything. Three of its channels (annotation.unresolved-directive,annotation.unsupported-threshold,annotation.invalid-threshold) are configuration errors that end the run regardless offail_on;annotation.unused-directivereports a directive that is valid but no longer fires, at a configurableunused_directive_severity(defaultinfo). Diagnostics name what can be addressed, so@qmx-threshold annotation.unused-directiveis told that the string is a channel of the ruleannotation.directive. @qmx-ignore-filerequires--before a reason whenever the channel is omitted, since a bare word right after the tag would otherwise be read as the channel (annotation.unresolved-directive). The channel argument on@qmx-ignoreand@qmx-ignore-next-lineis mandatory, so--stays optional there.- Inline directives are validated after configuration resolves, so a
@qmx-ignorenaming a user-defined computed-metric channel such ashealth.cohesionresolves exactly like a statically declared one. - Whether path and namespace exclusions may silence a finding is now a declared property of the channel rather than something inferred from the spelling of the rule name. A new rule named
architecture.somethingno longer inherits immunity it did not ask for. - Abstractness treats a bare enum as neutral and leaves it out of the denominator; an
enum X implements Ystill counts as concrete, because implementing a declared contract is exactly the substitution point a plain list of literals lacks. NewimplementingEnumCountmetric;enumCountkeeps its meaning. - New rule
architecture.unassigned-classcounts analysed class-like declarations that landed outside every declared layer, without the vendor dependency-edge ends that drownarchitecture.coverage. Off by default; setmode: ignore|warn|erroron it (CLI--unassigned-class-mode). It reports an absolute count, so a baseline can ratchet it down, and it reads the same single walk asarchitecture.layer-violation. pending: trueon a declared layer reserves it for code not written yet and exempts it fromarchitecture.unreachable-layer. The newarchitecture.pending-layer-matcheddiagnostic reports the moment such a layer matches something — including when a broader layer declared earlier wins every one of those matches, which is where the declaration lies loudest.--format=jsonfordebug:layer-assignmentreturns the layer-assignment resolution (assigned layer, shadowing layers, criteria) as a machine contract, so an agent no longer parses the human report.-
architecture.potential-shadownow reports only a layer that is more specific than the one shadowing it, i.e. one declared too late to ever win in its own area. Plain overlap is no longer a finding: first match wins is the declared resolution mechanism, so the documented narrow-before-broad ordering — including a final**catch-all — is legal and silent. Pairs whose specificity cannot be compared (mid-pattern wildcards, capture templates,suffix/attributes/implements/extendscriteria) keep the diagnostic. -
A rule stops running as soon as the disable selectors, taken together, silence every level of every channel it emits —
--disable-rule=duplication.code-duplication:projectnow skips the memory-intensive duplication phase exactly as the level-free spelling does, instead of running it in full and filtering all of its output away. The same holds forarchitecture.circular-dependency:project, and for a union such as--disable-rule=coupling.cbo:class --disable-rule=coupling.cbo:namespace. One level of a two-level channel still leaves its producer running, and a channel whose levels come from configuration (computed.*/health.*) is never stopped by a selector naming one of them. - New output format
suppressed(--format=suppressed, orformat: suppressedinqmx.yaml) reports, as machine-readable JSON, exactly what a run held back from its report and why: every suppressed finding paired with the mechanism that removed it (an inline@qmx-ignore, a global or per-rule path/namespace exclusion, the accepted-level baseline, or--report=git:*narrowing) and, separately, any configured exclusion that matched nothing at all this run. The composition is a multiset, not a set — one finding can be removed by more than one mechanism, so per-mechanism counts do not add up to a number of suppressed findings, and the format says so. Capture is armed the same way by--show-suppressedor by selecting thesuppressedformat itself, so the per-rule exclusion counts either surface reports never disagree;--show-suppressedon text prose covers only inline@qmx-ignoreand per-rule exclusions, not the other mechanisms. A versioned snapshot of this repository's own composition lives underdocs/internal/generated/suppression/and is checked for freshness bycomposer check.
Breaking¶
- A rule no longer declares the group it is listed under;
bin/qmx rulesreads the group off the first dot-separated segment of the producer's name.RuleInterface::getCategory(), theRuleCategoryenum and the$categoryproperty ofRuleMetadataandProducerDeclarationare removed, replaced by a derivedRuleMetadata::$family. Nothing you write or read changes: the same producers are listed under the same headings,--grouptakes the same values (--group=code-smell,--group=health, …), and no channel name, rule name, metric key, configuration key, CLI flag, output field, exit code or baseline entry is affected — the declared category equalled the name's first segment for all 51 registered producers, so removing it removed a second spelling rather than a fact. Only PHP code that named the removed types has to change, and the change is mechanical: drop thegetCategory()implementation from a rule class, and readRuleMetadata::$family(astring) where aRuleCategorywas read before. A producer whose name yields no first segment ('',.orphan) is now refused while the container is built, instead of being listed under an empty heading. See ADR 0033. - The six built-in health dimensions are producers of their own;
computed.healthno longer names anything. A finding ofhealth.complexity,health.cohesion,health.coupling,health.typing,health.maintainability, orhealth.overallnow carries that dimension's own name in itsrulefield instead ofcomputed.health; a user-defined computed metric carriescomputed. Every surface that used to addresscomputed.healthnow addresses the dimension by its own name instead:qmx.yamlrules:section keys (rules: { health.cohesion: { ... } }, notrules: { computed.health: { ... } }),--disable-rule,--only-rule,only_rules/disabled_rules,exclude_namespace_channelskeys, and@qmx-threshold/@qmx-ignoredirectives.bin/qmx ruleslists 51 rules instead of 45, under two group headings that did not exist before —HealthandComputed— in place of theMaintainabilityheadingcomputed.healthused to be listed under. The debt breakdown (text/verbose output, JSONviolationsMeta.byRule, the HTML report'sruleNamefield) now shows up to seven rows for computed metrics instead of one. Baseline entries are unaffected — a baseline stores the channel, not the producer — so no regeneration is needed for this change alone. Two configuration switches that used to look identical now look almost identical instead of being the same:rules: { health.cohesion: { enabled: false } }stops the dimension from publishing findings, whilecomputed_metrics: { health.cohesion: { enabled: false } }removes the dimension itself and renormalizeshealth.overall's weights — see Health Scores. - A level is no longer part of a channel's name. The ten level-suffixed channels collapse into five, each declaring both of the levels it reports at:
complexity.cyclomatic.callable/.classbecomecomplexity.cyclomatic, the same forcomplexity.cognitive,complexity.npath, andcoupling.cbo.class/.namespaceandcoupling.instability.class/.namespacebecomecoupling.cboandcoupling.instability. The level was written twice — once in the name and once in thesubjectevery finding already carries — and only these five rules ever wrote it into a name; the sixhealth.*channels have always reported at three levels under one name. Where a level mattered, address it beside the name withchannel:level,levelbeing one ofcallable,class,file,namespace,project:@qmx-ignore coupling.cbo:namespacesilences the namespace aggregate and leaves the class findings reported, and the same pair works in@qmx-ignore-next-line,@qmx-ignore-file, in--only-rule/--disable-rule/only_rules/disabled_rules, and inexclude_namespace_channelskeys. Three things to change by hand. Rename every occurrence of the ten old names — selectors, directives,exclude_namespace_channelskeys,baseline:explain --channel— dropping the level segment and, where the distinction was wanted, adding:levelinstead; a.classsuffix left in place now names no channel and is refused by name rather than silently matching nothing. Regenerate the baseline (bin/qmx baseline:generate <baseline> <paths...> --force) and review it: entries on the ten old channels go inert as undeclared. And expect thegetFingerprint()-derived identifiers of findings on these five channels to reset once — the channel name is the fingerprint's first component — so previously-seen GitLab Code Quality findings show as new and closed GitHub code scanning alerts reappear as open.bin/qmx rulesis unchanged — it lists 45 rules, and a level was never one — while the channel count drops from 57 to 52; no rule name, metric key, configuration key, CLI flag or exit code changes, per-level configuration keeps its nested form (coupling.cbo: { class: ... },--rule-opt coupling.cbo:class.warning=), and the baseline file is still version 13. @qmx-thresholdrefuses achannel:levelpair instead of retuning the whole rule. A threshold addresses the producing rule and does not distinguish levels (ADR 0024 §2); the pair is captured by the directive grammar so that it can be named and refused, where before the pattern stopped at the:and quietly applied the left half to every level. Set a per-level boundary with the nested configuration key or--rule-opt RULE:level.option=value. The same widening applies to the three@qmx-ignoreforms, where a pair used to be truncated to the bare channel — a suppression silently broader than the one that was written.- A channel is named by one name. The
ruleName#violationCodepair is gone: a channel's identity is what used to be the code half, and the rule that produces it stays a separate published field (rulein--format=json) and an edge of the registry. Everywhere the pair used to be written, write the channel name alone —@qmx-ignore complexity.cyclomaticinstead of@qmx-ignore complexity.cyclomatic#complexity.cyclomatic.callable, the same for@qmx-ignore-next-line,@qmx-ignore-fileand@qmx-threshold, for--only-rule/--disable-rule/only_rules/disabled_rules, for per-ruleexclude_namespace_channelskeys, forbaseline:explain --channel, and for thechannelfield of a baseline entry. The old spelling is refused, not ignored: every one of those surfaces answers with the name to write instead, so a stale directive fails loudly rather than silencing nothing in silence. Two consequences you cannot avoid. Baseline entries whosechannelstill carries a#go inert as malformed, so regenerate withbin/qmx baseline:generate <baseline> <paths...> --forceand review the result. And thegetFingerprint()-derived identifiers GitLab Code Quality and SARIF use to track a finding across runs are reset, because the channel key is their first component: expect previously-seen GitLab findings to show once as new, and closed or dismissed GitHub code scanning alerts to reappear as open. No channel name, rule name, metric key, configuration key, CLI flag or exit code changes, and the baseline file is still version 13. - A computed metric may no longer take a name that a registered rule or a declared channel already has.
computed_metrics: { computed.health: ... }was accepted and produced a channel addressed by the same string as the rule producing it; a channel is one name now, so the two would be one address for two different things, and the run ends with a configuration error naming the metric instead of resolving the collision silently in the static half's favour. - A finding is called a finding. The PHP class
Qualimetrix\Analysis\Finding\Contract\Violationis nowQualimetrix\Analysis\Finding\Contract\Finding,ViolationChannelisFindingChannel, and the property, parameter and named argumentviolationCodeiscode. Every derived type follows:ViolationFilterInterface,ViolationFilterStage,ViolationFilterStageInterfaceandViolationFilterStageResultbecomeFindingFilter*;Reporting\Filter\ViolationFilterbecomesFindingFilter;MeasuredViolationSetbecomesMeasuredFindingSet;ViolationFilterOrchestratorbecomesFindingFilterOrchestrator;ViolationSorter,ViolationDetailRenderer,DetailedViolationRenderer,ViolationSummaryRenderer,JsonViolationSectionandHtmlViolationPartitionerbecomeFindingSorter,FindingDetailRenderer,DetailedFindingRenderer,FindingSummaryRenderer,JsonFindingSectionandHtmlFindingPartitioner. Nothing you write or read changes: no channel name, rule name, metric key, configuration key, CLI flag, exit code, field in any output format, or baseline entry — the JSON finding field was alreadycode, the HTML report's embedded payload still spells its three aliasesruleName/violationCode/symbolPath, and the baseline file is still version 13. Residual spellings are measured by repository checks rather than frozen in this release note. Only code that imports these classes by name has to change, and the change is mechanical. Violation::$levelis removed rather than renamed. Five hierarchical rules wrote it and nothing read it except the object's own copy inreportedAsBreach(); a finding's level is carried by itssubject, which is where it was already read from. No published field, channel name or baseline entry ever carried it, so there is nothing to migrate unless you constructed the class yourself with alevel:argument.design.type-coverageis three rules:design.param-type-coverage,design.return-type-coverageanddesign.property-type-coverage, one channel each, each with its own threshold, suppression and baseline entry. Migration is mechanical. Configuration: onedesign.type-coveragesection withparam_warning/param_error/param_thresholdand thereturn_*/property_*equivalents becomes three sections keyed by the new names, each taking barewarning/error/threshold; the camelCase spellings (paramWarning, …) are gone with them. CLI: the six flags--type-coverage-{param,return,property}-{warning,error}become--{param,return,property}-type-coverage-{warning,error}. Selectors:--disable-rule=design.type-coveragematches nothing — name the three, ordesign.*.@qmx-threshold design.type-coverage W Eno longer retunes all three dimensions at once; it names a rule that does not exist, and each dimension is retuned on its own. Baseline: entries fordesign.type-coverage#design.type-coverage.paramand its siblings must be renamed todesign.param-type-coverage#design.param-type-coverageand siblings, or regenerated.bin/qmx rulesreports 45 rules instead of 42; the channel count is unchanged at 57, because the split moved a channel's owner rather than adding channels. See ADR 0030.- The
architecture.unassigned-classgate moved to its own rule.rules: { architecture.layer-violation: { unassigned_class: warn } }becomesrules: { architecture.unassigned-class: { mode: warn } }, and--layer-violation-unassigned-classbecomes--unassigned-class-mode. The rule has noenabledkey:mode: ignoreis how it is declined. Neither--disable-rule=architecture.layer-violationnorrules: { architecture.layer-violation: { enabled: false } }silences it any more — the selector addresses the two producers separately, and the walk they share runs for either of them while each checks its own gate before reporting. That is the point: the two answer different questions. Its position in the published channel order moves from 45 to 50 (channels are yielded grouped by producer, and the five declaration verdicts stay with the layer-violation rule), which is observable only in adid you meantie-break; no name is close enough to this one to tie, and that is measured rather than assumed. See ADR 0030. SymbolLevelandSymbolLevelProjectionmoved fromQualimetrix\Analysis\Evidence\Measurement\ContracttoQualimetrix\Core\Symbol, next toSymbolType,SymbolPathandMetricSubject. The level is a coordinate of a symbol — it is declared by rules, read off the symbol byFinding::level(), written right of the colon inchannel:level, and filtered on by namespace-channel exclusions — so the capability that walks the aggregation tree reads the vocabulary but no longer owns it. Nothing you write or read changes: no channel name, rule name, metric key, configuration key, CLI flag, output field, exit code or baseline entry is affected. Only PHP code importing the two class names has to change, and the change is the namespace alone. See ADR 0034.- The rule layer's
RuleLevelis gone;SymbolLevelis now the project's one level vocabulary, andHierarchicalRuleInterfaceandHierarchicalRuleOptionsInterfacename it instead. No channel name, configuration key, report field or baseline entry changes. Two behaviours do, both in computed-metric configuration.levels: []emits nothing and is now treated as declaring no channel at all, so a baseline entry naming it goes inert instead of being compared. A repeated level (levels: [class, class]) is now refused as a configuration error naming the metric, where it used to report the same finding twice. - Renamed rule
design.lcomtocohesion.lcomand itsqmx rules --groupcategory fromDesignto a newCohesiongroup; the rule's algorithm, defaults, options, and CLI aliases are unchanged. Update every surface that names the old channel by string:qmx.yamlrule keys,--only-rule/--disable-ruleselectors (design.*no longer sweeps LCOM in; usecohesion.*orcohesion.lcom),@qmx-ignore/@qmx-thresholddirectives, and baseline entries. A baseline entry still namingdesign.lcomdoes not fail the run — it degrades throughInertEntryReason::UndeclaredChannel, the same fail-safe path any channel a baseline entry no longer resolves to already takes. See ADR 0060. design.data-classwas inverted on its reported axis and could not report a Data Class at all.wocmeasured visibility (public methods / all methods), so any class whose methods are all public scored 100; the rule then flagged a high value, i.e. small classes with a plain public API, while two exclusions (isDataClass, andminMethodscounted against non-accessor methods) removed every real data class — including the rule page's own "Flagged" example.wocis now the Lanza & Marinescu ratio: non-accessor public methods over all public members (public methods plus public properties), with a class that has no public members scoring 100. The rule gates onwoc <= woc_threshold, whose default changes from 80 to 33, and its channel direction is nowLower. Accessor-ness is decided by method name, never by body, so a public method that only forwards to a collaborator counts as behaviour. The constructor counts on neither side of the ratio, and the size floor is now counted in members rather than methods:min_methodsbecomesmin_members(--data-class-min-members) and sums declared methods and properties, so a struct of public fields is finally within the rule's reach. Three migration steps, none optional: rewrite a configuredwoc_threshold(the old value is not merely stricter, it means the opposite), renamemin_methodstomin_members, and regenerate any existing baseline withbin/qmx baseline:generate <baseline> <paths...> --force— itsdesign.data-classmagnitudes were stored under the old channel direction and would be read as breaches. Thewocvalue in--format=metrics/jsonoutput changes for every class. ADR 0027 records the rationale.- A declaration's stored identity no longer contains its position in the file. A subject key is now
declaration:{logical}@{file}plus#{n}when the same logical identity is declared more than once in that file, wherenis the declaration's rank in the file — so inserting a blank line above a class no longer rewrites its key. The name minted for an anonymous class changed with it, from{anonymous@<byte offset>}to{anonymous#<rank>}. Baseline files are now version 13; a version 12 file is rejected, because the position it stored cannot say which declaration it meant, and must be regenerated withbin/qmx baseline:generate <baseline> <paths...> --forceand reviewed. The same change resets thegetFingerprint()-derived identifiers GitLab Code Quality and SARIF use to track findings across runs, and irreversibly resets theoccurrencekey ofarchitecture.layer-violationbaseline entries, whose evidence contains the declaration key (no other channel's does —architecture.circular-dependencykeys its evidence by logical paths and is unaffected): expect previously-seen GitLab findings to show once as new, and closed or dismissed GitHub code scanning alerts to reappear as open. See ADR 0026 for the property this guarantees and the three cases it deliberately does not: a closure, the methods and property hooks of an anonymous class, and a declaration sharing its logical identity with another in the same file. Each is a rank, so adding, removing or moving the siblings it counts renumbers it — and because a vacated rank is reused, such an entry does not go stale, it silently rebinds to the declaration that now holds the number. - An
exclude_namespace_channelskey must now address a channel the rule it is written under actually produces, at a level that key can ever be matched at; a key that does not ends the run with exit code 3 instead of being accepted and excluding nothing. Three refusals, each of which used to be an accepted no-op. A key naming another rule's channel is answered with the owning rule's channels. A key carrying a level is judged as one thing — produced by this rule and reporting at that level — socoupling.*:namespaceundercoupling.class-rankis refused, where before the level was witnessed bycoupling.cboand the production bycoupling.class-rank. Andnamespaceis the only level such a key may name, because the option is offered namespace-aggregate findings and nothing else:coupling.cbo:classis refused by name, with the spelling that works. Level-free keys are unchanged, andchannel:namespaceexcludes exactly what the barechanneldoes. - Baseline files are now version 12. A magnitude-shaped entry no longer stores
countalongsidemagnitudes— it is redundant with the magnitude list's length, and a file that still writes both is refused as malformed — shrinking this project's own 232 such entries by 2 320 B. The semantic occurrence key is now 16 hex characters instead of 64, since its discrimination domain is one (subject, channel) pair, not the whole baseline (2 064 B saved across 43 entries). Both changes reset thegetFingerprint()-derived identifiers GitLab Code Quality and SARIF output use to track findings across runs: expect previously-seen GitLab findings to show once as new, and closed/dismissed GitHub code scanning alerts to reappear as open. There is no converter for either change or for the prior version; version 11 (and earlier) baselines are rejected and must be regenerated withbin/qmx baseline:generate <baseline> <paths...> --force, with the resulting acceptances reviewed like any other regeneration.
Fixed¶
- SARIF rule descriptors are now derived from each channel's producing rule instead of a hand-kept table: most rules previously received a generic humanised placeholder (e.g. "Complexity Cyclomatic Callable") instead of their real description,
duplication.*linked to the wrong documentation page, and several rule/violation-code arms in the old table could never match a real code at all. cohesion.lcomno longer counts__construct/__destructas graph vertices. Any constructor whose assigned fields no other stateful method reads shared no property-access edge with the rest of the class, so it previously landed in the LCOM graph as an isolated vertex and inflated LCOM by one — property promotion (public function __construct(private array $x) {}) is the guaranteed case, since a promoted parameter never emits a property-access node at all, and affects the large majority of PHP 8+ constructors, but the artifact was never limited to it. The exclusion is not one-directional: on php-parser's own source (no promoted constructors) it changed LCOM for 106 of 260 classes — 97 dropped and 9 rose, the latter a real disconnection the constructor's edges had been masking. Same treatment TCC/LCC already gave constructors and destructors.- A baseline entry whose identity is also claimed by an unreadable line beside it no longer suppresses. The documented rule — a duplicated identity makes an entry inert — counted only the lines the parser accepted, so a hand-edited pair was resolved by which of the two happened to parse.
- Repointed the six complexity CLI aliases (
--cyclomatic-warning,--cyclomatic-error,--cognitive-warning,--cognitive-error,--npath-warning,--npath-error) to thecallablelevel key, so they adjust thresholds again instead of silently no-oping after the method→callable rename. - Counted the
??=assignment-coalesce operator as a path-generating decision point in NPath complexity, matching the documented??/?->extension. - Duplication detection now skips pathological hash buckets (hundreds of positions from generated parser tables and keyword lists) instead of exhausting memory with unbounded pair evaluation.
- Rejected wrong-typed scalar config values (
cache.enabled,parallel.workers,memory_limit,include_generated) with a configuration error (exit 3) instead of silently falling back to defaults. - Surfaced invalid computed-metric formulas, corrupt or unconvertible baseline files, and out-of-repo
--report=git:*as configuration errors (exit 3) rather than an "Unexpected error" (exit 1). code-smell.debug-codenow reports at Error severity (as documented) and detectsdebug_zval_dump().exclude_namespaces(global--exclude-namespaceand per-rule) now suppresses occurrence-style code-smell and security findings, resolving the declaring namespace from the finding's subject instead of the file-level symbol path, which always carriednull.- AST cache invalidation now fingerprints file contents, so a same-size rewrite with a preserved timestamp cannot reuse stale analysis results.
- Made duplicate-code candidate discovery use bounded memory before exact verification, without dropping real duplicate candidates.
- Preserved exact discrete namespace sums so abstractness and count-gated rules do not lose a class through fractional aggregation.
- Applied local
@qmx-thresholdoverrides to Value Object constructor limits and recognize the top-level CBOscopeoption without a false unknown-option warning.
Breaking¶
-
fail_onno longer acceptsinfo. Allowed values arenone,warninganderror. Severityinfois now report-only: an Info-only run always exits 0, which makesseverity: infoa declaration of "observe, do not gate" instead of the old trick of configuring an unreachable threshold. To gate on a diagnostic shipped atinfo, raise that rule's own severity. This does not weaken baseline breach, which remains a separate gate for baselineable channels. -
Namespace selectors are matched by one shared primitive.
--namespacefiltering, health drill-down, worst-offender listings and thecoupling.distanceinclude_namespacesoption previously used private copies of a literal-prefix check. Two behaviours change: a glob pattern (App\*\Order) is now matched as a pattern rather than compared literally, and an empty selector now selects nothing instead of the global namespace. -
Selectors no longer swallow dotted descendants. A name matches exactly and nothing else:
architecture.coverageno longer selects a hypotheticalarchitecture.coverage.source. If you relied on a selector reaching a descendant channel, name the descendant or use the explicitX.*form. -
@qmx-thresholdno longer accepts a prefix or*.@qmx-threshold coupling 15and@qmx-threshold * 15are now errors. A threshold override addresses one rule by its exact name, and there is no group form at all — resetting thresholds across a family was a footgun, not a feature. Replace each with one directive per rule.@qmx-ignore *,@qmx-ignore-next-line *and a bare@qmx-ignore-fileare unaffected: they mean "no rule filter here", not "every rule name", and continue to work. -
@qmx-ignorerequires a channel name, not a rule name. For a rule that emits more than one channel, the rule name alone no longer suppresses anything:@qmx-ignore annotation.directivenames a rule, and the diagnostic lists the channels it produces. Rules whose one channel is named after them are unaffected. -
Group selectors require the star. A bare prefix such as
complexity,duplicationorcode-smellused to stand for the whole family and now selects nothing, which is reported rather than guessed at. Four surfaces are affected:disabled_rules/only_rulesin both spellings (the shippedlegacypreset useddisabledRules), the CLI--disable-rule/--only-rule, the per-ruleexclude_namespace_channelsmap keys, and the three inline suppression forms. Migration is mechanical:disabled_rules: [complexity]becomes[complexity.*]. Note thatX.*means strictly the descendants ofX— if a name is both a rule and a channel and you want both, write both.X.*on a rule that emits a single channel of the same name has no descendants and is rejected; write the exact name instead. -
A
rules:section key must be an exact rule name, and so must the owner in--rule-opt RULE:option=value. No prefixes, no stars. This closes a silent no-op:rules: { complexity: {...} }previously passed both validations and configured nothing, because options are applied by exact key. If your configuration carries such a key, it has never had any effect; move each setting under the rule that actually owns it (complexity.cyclomatic,complexity.cognitive,complexity.npath,complexity.wmc). You will now be told rather than left guessing why a threshold appeared not to apply. -
An unresolvable selector is an error, not an ignored warning. In configuration or on the command line, a selector that matches no registered producer, group, or channel ends the run with exit code 3 before any report is produced. Inline, it becomes an
annotation.unresolved-directivefinding, which is a configuration error and therefore gates regardless offail_on— including underfail_on: none— and cannot be accepted by a baseline or silenced by another@qmx-ignore. -
@qmx-thresholdon a disabled rule is no longer diagnosed. Whether a rule is switched on is an execution filter, not a fact about whether its name exists, so such a directive is now valid and silent. If you were reading that warning as "this annotation is dead", it will no longer appear; the annotation is simply waiting for the rule to be re-enabled. -
Removing a computed metric from
computed_metrics:invalidates the annotations that referenced it. A@qmx-ignorenaming a channel that no longer exists is a dangling reference and is reported like any other unresolvable name. Deleting a metric now means deleting the directives that address it. (Existing baseline entries for a vanished channel still go inert rather than failing — an old baseline is a stored artefact, not something you just wrote.) -
The three layer-diagnostic severity keys are removed.
unreachable_layer_severity,potential_shadow_severityandempty_template_severityno longer exist in either spelling, and the CLI flags--layer-violation-unreachable-layer-severity,--layer-violation-potential-shadow-severityand--layer-violation-empty-template-severityare gone with them. The layer-policy diagnostics —architecture.coverage,architecture.unreachable-layer,architecture.potential-shadow,architecture.empty-templateandarchitecture.pending-layer-matched— now report a mistake in the configuration rather than debt in the code: they fail the run unconditionally without consultingfail_on, and cannot be accepted by a baseline or suppressed by@qmx-ignore. A severity key there would have looked like a behaviour switch while changing nothing but a word in the report, so it was removed rather than silently clamped. Delete the keys; there is no replacement. To decline the coverage diagnostic entirely, setcoverage: ignorein the architecture section.architecture.layer-violationis unaffected — it is real code debt, and@qmx-ignore architecture.layer-violationand baseline entries still apply to it. -
The mixed configuration runtime surface was removed without compatibility aliases:
TransitionalResolvedConfiguration,TransitionalRuntimeConfiguration, its provider/holder, andConfigurationContextno longer exist. InvokeConfigurationPipelineInterface::resolve(ConfigurationResolutionRequest)and pass the concreteConfigurationDocumentonly to a named owner resolver. UseRunConfigurationResolverInterfacefor invocation data,FindingConfigurationResolverInterfacefor rule configuration, and the Cache, Parallel, Reporting, or Console resolver that owns the remaining value. This removes unrelated feature state from a universal runtime carrier. -
CollectorConfigHolder,CollectorRuntimeConfiguration, and their generic stores/configurable contracts were removed. The only configured collector is LCOM, so its exact value is nowAnalysis\Evidence\Cohesion\Contract\LcomCollectionConfiguration, applied through the Cohesion-owned store and worker contract. Consumers must not add another feature setting to a generic collector payload. -
Core\Progress\*,ProfilerHolder,NullProfiler, and Core-ownedSpanwere removed. Collection code importsAnalysis\Run\Contract\Progress\ProgressReporterInterface; instrumentation importsCore\Profiler\Contract\ProfilerInterface; Console uses the Profiler-owned session control/report contracts. These changes keep delivery modes and profiling state instance-owned rather than globally mutable. -
The YAML keys
namespace.strategy,namespace.composer_json,aggregation.prefixes, andaggregation.auto_depthwere removed and are now rejected as unknown. Project namespace discovery uses the invocation working directory'scomposer.json, and aggregation follows analyzed declarations. -
Private Symfony container references are now recorded as permanent exact
composition_bindingentries in the internal manifest. A binding has one DI source, private target, and observed container operation; it is not a public contract and does not authorize another source. Add a named public contract for cross-owner use, or add a reviewed exact binding for composition only. -
Qualimetrix\Core\Coupling\FrameworkNamespacesandQualimetrix\Core\Coupling\FrameworkNamespacesHolderwere removed with no compatibility shim. Coupling now owns its run-scoped framework-namespace state. Composition consumers must injectQualimetrix\Analysis\Evidence\Coupling\Contract\Configuration\CouplingConfiguratorInterfaceand callconfigure(ConfigurationDocument $document)for every run, including an empty document to reset prior state. Configuration producers keep using the canonicalcoupling.framework_namespacesdocument key; they must no longer read or construct a Coupling field on a generic configuration carrier. -
Finding and policy ownership moved without aliases or shims. Replace
Qualimetrix\Analysis\RuleExecution\*,Qualimetrix\Core\Rule\*, andQualimetrix\Core\Violation\*imports with theirQualimetrix\Analysis\Finding\*counterparts and consume only the named Finding contracts for rule configuration, execution metadata/statistics, filters, and violations. Source controls and annotation suppression moved fromCore\Suppression/ Baseline internals toAnalysis\Policy\Inline; baseline lifecycle and accepted-boundary types now live underAnalysis\Policy\Baseline. Impact ranking and technical-debt calculation moved toAnalysis\Evidence\Prioritization. Replace direct ConsoleViolationFilterPipelineand InfrastructureGitScopeFiltercomposition withReporting\FindingProjection\FindingProjectorand itsGitScopeQueryInterface; the shipped adapter isInfrastructure\Git\ReportingGitScopeQuery. The transitional Configuration rule-option, selection, output-format, and finding-exclusion fields were replaced by the corresponding Finding/Configuration contracts. Update test namespaces with their subjects; removed provider getters, concrete rule-list exposure, old pipeline/result types, and old FQCNs have no compatibility replacement. -
Computed metrics and Health moved without aliases or shims into the
Qualimetrix\Analysis\Evidence\ComputedMetricscapability. ReplaceQualimetrix\Configuration\ComputedMetricsConfigResolverandQualimetrix\Configuration\ComputedMetricFormulaValidatorwith the same-named classes at the new capability root. ReplaceQualimetrix\Configuration\ComputedMetrics\Contract\HealthFormulaExclusionInterfacewithContract\Configuration\HealthFormulaExclusionInterfaceandQualimetrix\Configuration\HealthFormulaExcluderwithHealth\Configuration\HealthFormulaExcluderunder that root. Move remainingCore\ComputedMetric\*,Metrics\ComputedMetric\*,Rules\ComputedMetric\*, andReporting\Healthscore, offender, metadata, ranking, and drill-down imports to their corresponding root,Contract\*, andHealth\*declarations under the new capability; Reporting retains only thin report assembly and projection consumers. The evaluator API changes fromcompute($repository, $definitions)toevaluate($repository, $filesAnalyzed): definitions and configuration now belong to the injected, instance-owned catalog instead of caller-supplied or process-global state. Update imports and direct constructor calls, and wire the published ComputedMetrics contracts through DI; removed holder, evaluator-interface, Health builder-interface, and legacy drill-down surfaces have no compatibility replacement. -
Architecture implementation moved without aliases. Replace
Qualimetrix\Architecture\*imports with eitherQualimetrix\Analysis\Policy\Architecture\*for declared-layer policy orQualimetrix\Analysis\Evidence\CircularDependency\*for SCC evidence.ArchitectureProcessorInterface,ArchitectureLifecycleHook,AnalysisLifecycleHookInterface,CycleInterface, and the Configuration deferred-warning transport were removed. External consumers use only the new named leaf contracts; no compatibility shims are provided. -
Analysis orchestration moved without aliases. Replace imports under
Qualimetrix\Analysis\Pipeline\*,Analysis\Collection\*,Analysis\Discovery\*, andAnalysis\Lifecycle\*with theirQualimetrix\Analysis\Run\Contract\*,Analysis\Run\Collection\*,Analysis\Run\Discovery\*, andAnalysis\Run\Pipeline\*counterparts. In particular, useRun\Contract\Pipeline\AnalysisPipelineInterfacefor adapters andRun\Contract\FileSetInspectionParticipantInterfacefor the file-set invocation seam. There are no compatibility aliases. - Measurement moved without aliases. Replace
Qualimetrix\Analysis\Aggregator\*,Analysis\Repository\*,Analysis\Namespace_\*, and shared collection metric contracts withQualimetrix\Analysis\Evidence\Measurement\*. External consumers must use the correspondingMeasurement\Contract\*type; repository indexes, visitors, and aggregation implementations are internal. - Configuration moved without aliases from
Qualimetrix\Configuration\*toQualimetrix\Analysis\Configuration\*where P3 moved the type. The final surface isConfigurationPipelineInterface::resolve()returning concreteConfigurationDocument; use the named owner resolver rather than a generic provider or resolved-configuration carrier. The remaining rule-option and computed-metric classes are deliberate P5/P6 migration inputs, not compatibility shims. - Dependency extraction moved inside DependencyModel. Replace direct imports of
Analysis\Collection\Dependency\DependencyResolver,DependencyVisitor, and handler types with the declaredAnalysis\Evidence\DependencyModel\Contract\DependencyTraversalParticipantInterfacewhere an external promise is needed. Extraction internals have no public replacement. Tests move with their subject and must be discovered from their newtests/Analysis/...paths. - Dependency graph types moved without aliases: replace
Qualimetrix\Core\Dependency\Dependency,DependencyType, andDependencyGraphInterfacewith theirQualimetrix\Analysis\Evidence\DependencyModel\Contract\*equivalents; replaceEmptyDependencyGraphwith the internal capability implementation only inside composition. Replace the concreteAnalysis\Collection\Dependency\DependencyGraphBuilderdependency withContract\DependencyGraphBuilderInterface; graph implementations are no longer public module dependencies. - Graph export is now a Reporting projection contract. Replace
Analysis\Collection\Dependency\Export\GraphExporterInterfaceand directDotExporter/JsonGraphExporterconstruction withQualimetrix\Reporting\GraphProjection\Contract\DependencyGraphProjectionInterface::project()plusGraphProjectionRequest. The old exporter interface was removed, implementations moved underReporting\GraphProjection, and no compatibility aliases or shims are provided. - Duplication implementation moved without aliases or shims: update
Qualimetrix\Analysis\Duplication\*,Qualimetrix\Core\Duplication\*, andQualimetrix\Rules\Duplication\*imports toQualimetrix\Analysis\Evidence\Duplication\*. The intermediateDuplicationInspectionInterfaceis removed; final composition registers the internalDuplicationDetectoras an implementation ofQualimetrix\Analysis\Run\Contract\FileSetInspectionParticipantInterface. RemoveduplicateBlocksarguments/reads fromAnalysisContextandEnrichmentResult; no Duplication-owned public inspection contract remains. The capability-owned rule reads the internalDuplicationResultProvider. architecture.allownow rejects every directed cycle made only of exact selectors withConfigLoadException. Exact self-references were previously stripped silently and exact mutual permissions only warned; remove redundant self-edges and break or reorient at least one allow edge in every cycle. Glob and captured selectors remain outside this static DAG check.- Callable-level contracts now use
Callableinstead ofMethod, including symbol/rule levels and*.callablechannels. Update enum cases, configuration selectors, stored channel names, and integrations; there are noMethodaliases. - Baselines now require version 11 typed subjects with optional semantic occurrence and dependency-edge identity. Version 5 and version 10 files are rejected because exact declaration identities cannot be inferred; run a fresh analysis, deliberately map or split accepted entries, and write a reviewed v11 file. The historical
baseline:migratecommand was removed and has no replacement shim. - Violation JSON and fingerprints now use exact declaration subjects plus semantic occurrence and dependency edge where present. Consumers that persisted or joined findings by logical
symbolalone must usechannel + subject + optional occurrence + optional edge. - A
ViolationcarryingdependencyTargetwithoutdependencyTypenow emits JSONedge: {"target": "..."}instead ofedge: null, and its GitLab/SARIF fingerprint includes a collision-safe target-only edge component instead of colliding with the no-edge finding. No-edge and fully typed edge fingerprints are unchanged; baseline v11 already retained target-only edges, so no baseline migration is required. - Layer-violation findings now project an owned logical target to each exact target declaration; an unowned target remains on the exact source declaration. Update symbol-scoped suppressions and baseline mappings to the projected target subject; physical next-line/file controls still use the dependency use-site.
CollectionOrchestratorno longer creates default null progress/logger collaborators. Pass mandatory Run progress and PSR logger instances; shipped DI injects the instance-owned Console progress switch andDelegatingLogger, while every direct constructor call must provide its chosen implementations. No nullable/default overload or compatibility shim is provided.FileParserInterfacenow requiresparseContent(SplFileInfo $file, string $content): array. Implementations must parse the supplied bytes while using$fileas diagnostic source identity.DependencyGraphAnalyzerInterface,DependencyGraphAnalyzer, andDependencyGraphAnalysisResultmoved fromQualimetrix\Analysis\Collection\DependencytoQualimetrix\Analysis\Pipeline. Update imports and fully qualified type references to the new namespace; their constructor and method contracts are unchanged, and no compatibility aliases are provided.
0.24.0 - 2026-08-08¶
Changed¶
- Baselines now use version 10 reported-magnitude ceilings: an accepted live group can fail only when its count or reported magnitude worsens, while a stale or inapplicable entry is reported without disabling the remaining baseline. Create files with
bin/qmx baseline:generate <baseline> <paths...>and maintain them withbaseline:updateor explicitly selectedbaseline:cleanup --removeentries. - Every output format now carries an explicit analysis-coverage verdict, including zero-file and generated-only runs. Parse or processing failures make policy results non-authoritative and return exit code 4; JSON/metrics expose a structured
coverageobject, SARIF uses invocation notifications, CI formats emit native failure records, and human/HTML reports show an explicit warning. - Namespace LOC and structural metrics are attributed to every namespace block in multi-namespace files, while project totals continue to count each physical file exactly once. Git report scoping likewise indexes every namespace declaration in a changed file.
Breaking¶
AnalysisConfiguration::isRuleEnabled()andisViolationCodeEnabled()were removed because configuration no longer owns selection semantics. InjectRuleSelectorand pass the producer name plusViolationChannel; this preserves channels whose producer name, channelruleName, andviolationCodediffer.- Baseline file format v5 was removed. Convert an existing file with
bin/qmx baseline:migrate <baseline> <paths...>; migration makes a fresh v10 capture because v5 has no recorded magnitude boundary. check --generate-baseline=<file>was removed. Usebin/qmx baseline:generate <file> <paths...>instead.--baseline-ignore-stalewas removed. Stale entries now report without failing a run or disabling other baseline entries; inspect and remove only explicitly selected entries withbin/qmx baseline:cleanup <baseline> <paths...> --remove=<selector>.--no-suppressionwas renamed to--no-suppression-annotationswith no alias. It is report-only: annotated findings are restored after baseline measurement, so the flag no longer widens the measured set or promotes an annotated finding to Error.- The
Cycle data:JSON trailer of anarchitecture.circular-dependencyrecommendation now lists fully qualified class names in itscyclearray, where it used to list bare class names. The trailer exists to be machine-read, and a bare name does not identify a class. The keys and the shape of the object are unchanged; consumers matching on a short name must match on the fully qualified name or its trailing segment. Baseline entries are unaffected — they are not keyed by the recommendation. - Baseline entries for
architecture.circular-dependencymust be regenerated. Cycles are now keyed by the canonically smallest class of the cycle, so any recorded entry whose key was a different member no longer matches and the cycle is reported as new. For a v5 file runbin/qmx baseline:migrate <baseline> <paths...>; for a v10 file, review the capture and replace it withbin/qmx baseline:generate <baseline> <paths...> --force. Entries for other rules are unaffected. @qmx-thresholdaccepts only a non-negative numeric shorthand or the genericwarning=N/error=Nkeys (one or both, in either order). Arbitrary YAML /--rule-optoption names and trailing prose that were accidentally accepted by substring matching are now rejected; put an optional non-empty reason after--or an em dash (—). Prefix and wildcard rule patterns remain supported but skip per-rule validator checks, so exact rule names are recommended.- Incomplete analysis no longer succeeds or returns a warning/error policy code:
check, baseline lifecycle commands, andgraph:exportreturn exit code 4. Baseline writers and graph export refuse partial artifacts even with--force; existing destinations remain byte-identical. - Maintainability Index now consumes the Size metric
methodStatementCount; the Halstead-ownedmethodLoc/halstead.methodLocmetric was removed. RenameminLoctominStatements, YAML and--rule-optkeymin_loctomin_statements, and CLI alias--mi-min-locto--mi-min-statements. No compatibility aliases remain. MI values, aggregates, health scores, thresholds, and baselines may shift. - NPath now retains nested expression contributions through AST wrappers, counts every
matcharm, nullsafe access, and expression-bearingfor,foreach,switch, andechoslots. Existing NPath values, thresholds, and baselines may shift.
Fixed¶
--only-rulenow selects the full finding channel instead of assuming a rule name prefixes itsviolationCode.--only-rule=computed.health,--only-rule=health.complexity, and--only-rule=computed.health#health.complexitynow all run thecomputed.healthproducer and retain the intended findings; Architecture diagnostic channels such asarchitecture.coverageand baseline lifecycle commands use the same selection contract. Valid channel selectors no longer trigger the false "does not match any registered rule" warning.architecture.circular-dependencynow identifies a cycle by its smallest member instead of by whichever member the graph traversal happened to reach first. The reported symbol, the displayed cycle path and the order of reported cycles used to depend on file discovery order, so adding an unrelated file could re-key an existing cycle: its baseline entry looked resolved and the same cycle reappeared as a new violation.architecture.circular-dependencyno longer renders every member of a cycle by its bare class name. Members of the same cycle that share a class name now carry the shortest trailing namespace suffix that tells them apart, so a cycle betweenApp\Billing\ServiceandApp\Orders\ServicereadsBilling\Service → Orders\Service → Billing\Serviceinstead of the uselessService → Service → Service. Members whose short name is unique in the cycle are unchanged. This also changes the GitLab Code Quality fingerprint of an affected violation, so one such cycle will be reported as resolved and re-raised once.@qmx-thresholdparsing now validates the entire value expression instead of acceptingwarning=orerror=substrings hidden inside unsupported syntax. The documentation now also reflects the actual scope rule: a class override applies to evaluations inside the class, including its methods, while the smallest matching source span wins.- Rules that share one Options class now receive producer-specific Options instances; configuring one code-smell or security rule no longer silently configures another rule that reuses the same immutable class.
- Unknown
--only-rule/--disable-ruleselectors and unknown rule-option owners now fail closed as input errors (exit 3) before a report payload is written instead of warning and continuing with an unintended rule set. checkdiagnostics are routed to stderr, keeping stdout valid for the selected report format even on configuration/input errors, deprecations, logging, and output-file notices.baseline:explainnow rejects a symbol absent from both the current analysis and baseline, while labelling baseline-only symbols explicitly instead of presenting a misspelling as a clean result.
0.23.0 - 2026-07-29¶
Fixed¶
code-smell.boolean-argumentno longer flags promoted constructor properties (public bool $x) by default — a promoted parameter declares a field, not a behavior switch, so the rule's "split into two methods" advice never applied to it. Setflag_promoted_properties: trueto restore the previous behavior.duplication.code-duplicationno longer flags a duplicate block that lies entirely inside aconstarray or a static/instance property's array-literal initializer — repeated key/value shape across the rows of a data table is normal, and "extract a shared method" was never actionable advice for it. A block that extends past the declaration into surrounding code is still reported, so two otherwise identical classes wrapping the same table remain a finding.
0.22.0 - 2026-07-28¶
Fixed¶
- Rule options set through the config file or
--rule-optwere silently ignored when the option name had more than one word (vo-warning,param_threshold, …), while the dedicated CLI flag for the same option worked but printed a bogusUnknown optionwarning. All three channels now agree, and--preset=strictapplies itsvo-errorvalue instead of dropping it. Values were dropped forcode-smell.long-parameter-listanddesign.type-coverage;coupling.distanceonly ever suffered the false warning. - The documented
threshold:shorthand crashed the whole run withCannot mix "threshold" with "warning"/"error"(exit code 3) whenever it was written at the top level of a rule, which is exactly howwebsite/docs/getting-started/configuration.mdshows it. 15 rules were affected; the nestedmethod: {threshold: …}form was never broken. - The
threshold,vo-thresholdand*_thresholdshorthands no longer produce a falseUnknown optionwarning on rules that support them. coupling.cboandcoupling.instabilitynow accept thethresholdshorthand at the rule's top level, applying it uniformly to the class and namespace dimensions. They were the only two threshold rules of twenty that rejected it, answering a barethresholdwith a bewilderingUnknown optionwarning.code-smell.long-parameter-listnever applied itsvo-warning/vo-errorthresholds: value-object constructors were reported against the ordinary thresholds instead. The VO detection flag never reached the rule.architecture.unreachable-layerno longer fires for layers that only ever match as the target of a dependency — such as vendor boundary layers (ClickHouseDB\**). Such a layer was reported unreachable in the same run wherearchitecture.layer-violationflagged a real edge into it.- Parameter and return types of closures and arrow functions are now collected into the dependency graph. Previously only their bodies were, so a layer violation that entered exclusively through a closure signature was invisible to
architecture.layer-violation, coupling metrics andgraph:export. - Global
exclude_namespaces(and--exclude-namespace) no longer suppressarchitecture.*violations. Silencing a noisy metric in a namespace used to switch off layer-policy enforcement there as a side effect. Per-rule exclusions still work — see below.
Changed¶
- Severities of
architecture.unreachable-layer,architecture.potential-shadowandarchitecture.empty-templateare configurable viaunreachable_layer_severity,potential_shadow_severityandempty_template_severityon thearchitecture.layer-violationrule. Defaults are unchanged, so a typo inpatterns:can now fail the build instead of only whispering at info level. - Violations dropped by per-rule
exclude_namespaces/exclude_pathsare now reported:-vprints how many were suppressed and by which rules, and--show-suppressedlists them in a block of their own, separate from@qmx-ignore. They used to disappear without a trace — on this repository's own configuration that hid 387 violations.
Breaking¶
architecture.coveragewithcoverage: warnnow reportsWarningseverity instead ofInfo, matching the mode's name. If you relied on it staying silent underfail_on: warning, switch tocoverage: ignoreor raisefail_ontoerror.- Dependency graph gained edges: parameter and return types of closures and arrow functions, plus attributes on their parameters. Coupling metrics that read the graph — CBO, ClassRank, instability, distance and the derived health scores — shift accordingly, and
architecture.circular-dependencymay report cycles that were previously invisible. Thresholds tuned against the old graph may need revisiting; a baseline generated before this release stays valid only for violations whose identity did not change. ThresholdParser::parse()replaced thelegacyWarningKeys/legacyErrorKeysparameters with a singlelegacyKeysarray keyed bywarning/error/threshold. Named-argument calls fail withUnknown named parameter; the old positional form silently loses its legacy keys. Only affects third-party rule packages calling the parser directly.RuleExecutorInterfacegainedgetRuleExclusionStats(). Third-party implementations of the interface must add it.ViolationFilterOrchestrator::__construct()takes an additional requiredRuleExecutorInterfaceargument. Only affects code constructing it directly; the container wires it automatically.
0.21.0 - 2026-07-28¶
Fixed¶
qmx rulescrashed with a fatalArgumentCountErrorinstead of listing the rules. The command built rule objects itself, which breaks for rules that take constructor dependencies besides their options (architecture.layer-violation). Rule instances now always come from the DI container;qmx checkwas never affected.
Breaking¶
RuleRegistryInterface::getAll()removed — it could not build rules that declare constructor dependencies beyond their options. Embedding consumers that need rule instances should take them from the container (tagqmx.rule);getClasses()andgetAllCliAliases()still cover metadata.RulesCommand::__construct()now takesiterable<RuleInterface> $rulesinstead of aRuleRegistryInterface. Only affects code that constructs the command directly; the container wires it automatically.
0.20.1 - 2026-07-28¶
Fixed¶
- Qualimetrix reported the consuming project's version as its own.
qmx --versionprinted things like1.0.0+no-version-set, and the same wrong value was stamped into every analysis artifact —versionin JSON and SARIF,toolVersionin the metrics format, and the HTML report footer. The version is now resolved by package name instead of through Composer's root package, which is the host project whenever Qualimetrix is installed as a dependency.
0.20.0 - 2026-07-28¶
Earlier releases can no longer be installed. The repository history was rewritten to remove content that should never have been published. Every tag before v0.20.0 now points at a commit that no longer exists, so
composer require qualimetrix/qualimetrix:<older version>fails with a 404 from GitHub. The published archives cannot be restored — upgrade to v0.20.0.
Security¶
symfony/yamlupdated to v8.0.14, clearing three advisories: a ReDoS via catastrophic backtracking in the parser cleanup regex (CVE-2026-45305), stack exhaustion via unbounded recursion in nested blocks (CVE-2026-45133), and CVE-2026-45304. Qualimetrix parses YAML configuration on every run, so this affects all users.symfony/cacheupdated to v8.0.14, clearing CVE-2026-45073. It is pulled in transitively bysymfony/expression-language, which backs computed metric formulas.
Breaking¶
AnalysisConfiguration::{projectRoot, cacheDir, composerJsonPath}are now typed asAbsolutePath/?AbsolutePathinstead ofstring/?string. Embedding consumers that constructAnalysisConfigurationdirectly must wrap path arguments inAbsolutePath::fromString(...). The no-arg constructor still works as before — defaults resolve lazily togetcwd()and${projectRoot}/.qmx-cache.fromArray()andmerge()continue to accept string values from YAML / CLI input and resolve them viaPathFactory::fromCliArgument(). ADR 0015 Phase 5.BaselineWriter::write()now requiresAbsolutePathfor the$projectRootparameter (was optionalstring = '.'). Embedded callers must wrap their project root and pass it explicitly.GitClient::getProjectRoot()accessor removed. The project root is now owned byGitScopeResolution(returned fromGitScopeResolver::resolve()); pass it explicitly to consumers that previously read it fromGitClient.FileProcessorInterfacegainssetProjectRoot(AbsolutePath): void.CollectionOrchestratorInterface::collect()gains a requiredAbsolutePath $projectRootparameter. Custom orchestrators / processors must add the method or update the call. ADR 0015 Phase 6.
Changed¶
- Configuration, cache, parallel pipeline, namespace detection, and dependency analysis now consume
AbsolutePath/RelativePathVOs at every internal boundary instead of untyped strings. The migration closes the path-type ambiguity that motivated the T10 git-subdirectory bug class. ADR 0015 Phase 5. - Git infrastructure now uses typed
AbsolutePathandRelativePathVOs instead ofstringthroughoutGitClient,GitRepositoryLocator, andGitScopeFilter. ADR 0015 Phase 1b. GitScopeFilternow performs eager git-to-project path translation at theGitClientboundary. Project roots that sit in a strict subdirectory of the git tree (T10) are now handled correctly: changed files outside the project are filtered out early, and namespace extraction for violations is resolved against the project root instead of the git top-level.- The project's own dogfooding
qmx.yamlnow declares the full 27-layer architecture topology (Core + Configuration + Architecture slice + per-categorymetrics-{Category}template + 10analysis-*sub-layers + 10infra-*sub-layers) that previously lived indeptrac.yaml. Sub-layer enforcement (e.g.analysis-discovery → analysis-pipelineis now caught) gained, on top of features deptrac never had: per-category metric isolation via template expansion, and arelations:filter that permitsinfra-di → metrics-*references but forbids inheritance. ADR 0014. Violation::$location->$fileis now typed as?RelativePath(wasstring). Architecture violations not tied to a single file useLocation::none()(file isnull). Wire/comparator surface preserved viaLocation::pathString()andLocation::isNone()— formatters and JSON output emit the same shape as before, but file paths going intoLocationmust be project-relative.WorstOffender::$fileandDuplicateLocation::$filemigrated similarly;ParseException::$filePathnow carriesAbsolutePath(lives at the parser boundary, where absolute paths are the natural representation). ADR 0015 Phase 1a.
Removed¶
deptrac/deptracdev-dependency.composer checkis nowcs-check + test + phpstan + selfcheck; architecture enforcement runs entirely through Qualimetrix's ownarchitecture.layer-violationrule.- Internal
Qualimetrix\Core\Util\PathNormalizerhelper (was@internalsince v0.18). Superseded byCore\Path\PathFactory. ADR 0015 Phase 6 also wires a PHPStan rule (qmx.bannedStringPathProperty) as a regression guard against re-introducingstring-typed$file/$filePath/$oldPathproperties in scoped namespaces.
Fixed¶
- The HTML report build manifest (
src/Reporting/Template/package.jsonand its lockfile) is now tracked. A blanket*.jsonignore rule had been excluding it, so a fresh clone could not runcomposer test:jsorcomposer build:js, and the committeddist/bundle could not be regenerated or audited.
0.19.0 - 2026-05-17¶
Breaking¶
ThresholdAwareOptionsInterfacegains a staticgetOverrideValidator()accessor that returns the per-ruleOverrideValidatorInterfacestrategy used to validate@qmx-thresholdannotations. Custom Options classes in extension code must implement the new method oruse StandardOverrideValidatorTrait;for the defaultwarning ≤ error + non-negativesemantics. See ADR 0013.
Changed¶
- Invalid
@qmx-thresholdannotations now surface a rule-specific code (e.g.warning_exceeds_error,error_exceeds_warning,error_not_supported) asviolationCode: annotation.invalid-threshold.<code>in JSON / SARIF / Checkstyle output; the human message is unchanged. Validators that provide a remediation hint (e.g. WarningOnly's "omiterror=...") now flow through torecommendationso users see actionable follow-up.
Fixed¶
@qmx-threshold maintainability.index warning=N error=Mannotations withN > Mwere silently rejected by the parser, even though the rule's defaults arewarning=40 error=20(inverted thresholds are the natural orientation). The bug was latent across releases — Maintainability annotations work for the first time in v0.19.@qmx-threshold design.type-coverage warning=N error=MwithN > Mwas rejected on the same parser invariant; type coverage is an inverted-threshold rule and now accepts the natural form.@qmx-threshold design.data-class warning=N error=Mwas rejected whenN > M, but the rule maps warning towocThreshold(high) and error towmcThreshold(low) — independent metrics on independent axes. The annotation now validates accordingly.@qmx-threshold design.god-class warning=W error=Epreviously accepted theerrorvalue silently and then discarded it insidewithOverride(). Expliciterror=Nis now rejected at parse time with a clear diagnostic; the shorthand form@qmx-threshold design.god-class Nstill works.
0.18.0 - 2026-05-16¶
Breaking¶
architecture.layersYAML schema is now an ordered list (long form only), not a map. The first layer whose patterns match a class FQN owns the class — declaration order is meaningful. Migration: replacelayers: { name: pattern }withlayers: [{ name: x, patterns: [pattern] }]. See ADR 0006.RuleInterface::getCliAliases()removed. CLI aliases are now declared via the repeatable class-level attribute#[CliAlias('alias', 'optionName')]. Custom rules in extension code must drop the method and add attributes on the class.- Architecture-feature classes moved to a vertical slice under
Qualimetrix\Architecture\{Domain,Configuration,Processing,Rules}per ADR 0010. Extension authors importing from the oldQualimetrix\Core\Architecture,Qualimetrix\Configuration\Architecture,Qualimetrix\Analysis\Architecture, orQualimetrix\Rules\Architecturenamespaces must update imports.
Changed¶
- New rule
architecture.layer-violation: declare layers in YAML and enforce allowed inter-layer dependencies. Membership supportspatterns,suffix,attributes,implements,extends(combined viamatch: any | all); parameterised template layers expand against the observed class set ({var}capture);exclude:blocks hard-filter assignment; allow-listrelations:whitelists restrict permittedDependencyTypekinds; capture-binding ('app-{m}': ['domain-{m}']) constrains allows to same-instance edges for DDD bounded contexts. Incremental adoption viaarchitecture.coverage; expansion capped byarchitecture.max_expanded_layers(default 500). See ADRs 0006–0008. - New diagnostics:
architecture.empty-template(warning — template expanded to zero layers),architecture.unreachable-layer(info — layer pattern matched zero classes),architecture.potential-shadow(info — evidence-based detection of layers silently stealing classes from later, narrower layers). - New CLI command
debug:layer-assignment <fqn>: per-class introspection of layer assignment — reports the assigned layer and which other layers' patterns would also have matched. Runs full Discovery + Collection so output matchesqmx checkbyte-for-byte. qmx.yaml.exampleincludes a commented-outarchitecture:stanza demonstrating multi-criterion membership,exclude:, templates, vendor layers,allow:(plain, captured same-instance, long-form withrelations:),coverage, andmax_expanded_layers.
Fixed¶
@qmx-thresholdannotations ondesign.type-coverage,design.god-class, anddesign.data-classpreviously had no effect — the Options classes did not implementThresholdAwareOptionsInterface. The three Options now implement it and apply overrides per class.architecture.layer-violationnow respects@qmx-ignoresuppressions placed on the offending class — the dependency visitor used absolute paths while the suppression map was keyed by relative paths.architecture.layer-violationno longer false-positives mutual-allow when the two directions use disjointrelations:filters orallow_cross_instance: true.architecture.max_expanded_layersnow actually takes effect when set in YAML (previously silently camelCased and ignored). See ADR 0009.architecture.allowsource and target selectors now reject[brackets at config-load time with an actionable hint suggesting{var}capture-variable syntax.debug:layer-assignmentnow honoursmemory_limitfromqmx.yaml.- Architecture configuration warnings (e.g. mutual-allow detection) now actually reach the user logger.
- SARIF formatter
$schemaURL updated to the OASIS canonical location after the upstream repo reorganized.
0.17.0 - 2026-05-12¶
Fixed¶
health.typingno longer reports 0% for namespaces with no typeable declarations (e.g. marker interfaces used for Symfony Messenger routing). Empty type surface now yields 100% (vacuous truth) at namespace and project levels, matching the existing class-level semantic.- Disabling a health dimension via
computed_metrics.health.X.enabled: falseno longer breakshealth.overall. Bothenabled: falseandexclude_health: [X]now follow the same pipeline — the dimension is removed andhealth.overallweights are renormalized across the remaining dimensions.
Changed¶
- Excluding a health dimension when
health.overallhas been overridden with a non-canonical formula (one that does not match(health__dim ?? fallback) * weight) now throws an explicit error instead of silently dropping the formula. Custom formulas should handle disabled dimensions via??fallbacks.
0.16.0 - 2026-05-01¶
Changed¶
health.couplingnamespace formula rewritten to use efferent-only signals (ce.avg,ce_packages.avg,ce.max,ce, distance). Stable contracts namespaces (high incoming, low outgoing dependencies) are no longer unfairly penalized by bidirectional CBO. Class- and project-level formulas are unchanged.- New aggregations for the
cemetric at namespace and project levels:ce.avg,ce.max,ce.p95.
0.15.0 - 2026-04-04¶
Changed¶
- Strict configuration validation: unknown section sub-keys (
cache.typo), invalid value types (cache.enabled: "yes"), and unknown rule names (rules.complexty) now produce clear errors with "Did you mean?" suggestions - Warnings (e.g., unknown rule option keys) are now visible at default verbosity via stderr, without requiring
-v
Fixed¶
- Configuration warnings were invisible without
-vflag due toNullLoggerat default verbosity
0.14.0 - 2026-04-03¶
Changed¶
--exclude-namespaceCLI option for violation suppression by namespace (prefix or glob), merged withexclude_namespacesfromqmx.yaml
Fixed¶
- Computed metric names with underscores (e.g.,
computed.my_score) were incorrectly normalized to camelCase in YAML config
0.13.0 - 2026-04-03¶
Changed¶
--show-suppressednow lists each suppressed violation with file, line, message, and rule name (was count-only)exclude_pathsandexclude_namespacesnow support both prefix matching (src/Entity) and glob patterns (src/Metrics/*Visitor.php); simple directory/namespace names work without trailing/*--exclude-healthwith invalid dimension name now produces an error instead of silently ignoring
Fixed¶
- "No PHP files found" message shown when all files had parse errors — now shows "All N file(s) were skipped due to parse errors"
0.12.0 - 2026-04-03¶
Changed¶
- LCOM4 rule:
exclude_methodsoption to exclude specific methods from the cohesion graph (reduces false positives from interface-mandated methods likegetName,getDescription) - Partial scope warning when analysis paths don't cover all composer.json autoload entries
coupling.instability:min_afferentoption replacesskip_leaf— configurable minimum afferent coupling (Ca) threshold for skipping symbols (default: 1, skip Ca=0)code-smell.boolean-argument: parameters with common boolean prefixes (is*,has*,can*,should*,will*,did*,was*) are now allowed by default (configurable viaallowed_prefixes: [])code-smell.error-suppression:allowed_functionsoption to whitelist functions where@usage is acceptable (e.g.,fopen,unlink)- Per-rule
exclude_pathsoption for targeted violation suppression by file path patterns @qmx-ignoretags now work in regular comments (//,/* */), not just PHPDoc docblocks- JSON format (
--format=json) now outputs all violations by default (was limited to 50); use--format-opt=violations=50to restore the old behavior - Global
exclude_namespacesconfig option for suppressing violations by namespace prefix (likeexclude_pathsbut for namespaces) - Computed metric formulas referencing non-existent metrics now produce a clear error instead of silently failing
- Warnings (partial scope, unknown rules, missing composer.json) now go to stderr to avoid corrupting machine-readable output
- Exit codes: config/input errors now return exit code 3 (was 1, overlapping with "warnings found"). Scheme: 0=clean, 1=warnings, 2=errors, 3=config error
Fixed¶
graph:exportcommand crash due to-dshortcut conflict with global--working-dir
Removed¶
--analyzeoption — was misleading (analyzed all files regardless, only filtered violations like--report). Use--reportinsteadanalyzecommand alias — usecheckinsteadbaseline.json— replaced with properqmx.yamlconfiguration using new features
0.11.2 - 2026-04-02¶
Changed¶
- Project
qmx.yamlfor self-analysis with tuned coupling thresholds andexclude_namespacesfor Core value objects qmx.yaml.example— comprehensive annotated example with documentation links, default values, and all available options (replacesqmx.yaml.dist)parallelsection in config file for setting worker count (was CLI-only via--workers)
Fixed¶
couplingsection in config file was rejected as unknown key
0.11.1 - 2026-04-01¶
Changed¶
--memory-limitoption andmemory_limitconfig key to control PHP memory limit (e.g.,--memory-limit=1G)- Removed hidden 512M memory limit override — PHP's
memory_limitfrom php.ini is now respected by default
0.11.0 - 2026-04-01¶
Changed¶
- Cognitive Complexity violations include breakdown of top contributors:
Top: nested if +5 L12, foreach +4 L15, &&/|| +1 L22 - NPath Complexity violations include multiplicative chain:
Chain: ×6 if/else L25, ×4 match L31, ×3 switch L20
0.10.0 - 2026-03-29¶
Breaking¶
- Rule IDs
code-smell.god-classandcode-smell.data-classrenamed todesign.god-classanddesign.data-class --format=healthnow produces a text table (was HTML). Use--format=htmlfor the interactive HTML report
Changed¶
@qmx-thresholdannotations for per-class/method threshold overrides in source code- Framework CBO distinction:
cbo_appandce_frameworkmetrics separate application from framework coupling - Full dependency graph in
--analyze=git:*modes — coupling metrics now correct in partial analysis --group-by=class|namespacefor JSON output- Worst contributors per health dimension in
--format=health, configurable via--format-opt=contributors=N - Violation density metric (
violationDensity: violations per 100 LOC) in worst offenders - NPath violations include severity categories (low/moderate/high/very high/extreme)
- VO constructor exemption for
long-parameter-list— relaxed thresholds (vo-warning,vo-error) - LCOM4: stateless methods grouped together, reducing false positives on utility classes
- Duplication violations include content preview hint
- Martin Diagram view in HTML report with parent-namespace instability/abstractness/distance
- NamespaceTree: canonical namespace hierarchy replaces flat aggregator
- Warn when
@qmx-thresholdtargets rules that don't support overrides - Decomposed 13 large classes into focused components (SRP)
Fixed¶
- Health: complexity contributors always empty; recalibrated formulas for per-method aggregation
- Metrics: namespace
.max/.avg/.p95now aggregated from raw method values, not pre-aggregated class values - Reporting: aggregation suffixes stripped from metric keys in health text; uppercase metric keys fixed
- Git: absolute path mismatch in
GitScopeFilterfor--analyze=git:* - Security: hardcoded credentials no longer flag dot-notation identifiers (e.g.,
config.database.host) - Duplication: self-duplication for overlapping/adjacent ranges in same file eliminated
- Removed dead weighted average from aggregation, dead
GitFileDiscoveryclass
0.9.2 - 2026-03-26¶
Fixed¶
- CI: refactored
ConfigDataNormalizerto eliminate complexity violations (NPath 442K → 4), regenerated baseline
0.9.1 - 2026-03-26¶
Changed¶
- "Top issues by impact" redesigned: file path on the first line (clickable in terminal), rule name + message + symbol context on the second line. Shows
recommendationwhen available. Handles architectural violations ([project]) - HTML report: violations table now shows
Filecolumn, usesviolationCode(more specific thanruleName), and prefersrecommendationover technicalmessage
0.9.0 - 2026-03-26¶
Changed¶
- Analysis presets:
--preset=strict|legacy|cifor one-flag configuration. Multiple presets can be combined (--preset=strict,ci). Custom preset files supported via path (--preset=./team.yaml) ruleskey now uses deep merge across pipeline stages — partial rule overrides inqmx.yamlno longer replace entire preset rule configurations
0.8.0 - 2026-03-26¶
Changed¶
- Effort-aware prioritization: "Top issues by impact" section in summary and JSON output. Violations ranked by
classRank × severity × remediation time— answering "what should I fix first?" New--top=Noption (default 10,--top=0to disable)
0.7.1 - 2026-03-25¶
Changed¶
- CBO metric no longer counts PHP built-in classes (
Exception,DateTime,Iterator, etc.) — only project and third-party dependencies contribute to coupling scores. Dependency graph exports (graph:export) are also affected
0.7.0 - 2026-03-25¶
Changed¶
--fail-onnow defaults toerror— warnings are shown in output but don't cause non-zero exit code. Use--fail-on=warningorfail_on: warningin config for the old behaviorthresholdshorthand for rule configuration — sets both warning and error to the same value, making all violations errors at that threshold- Health score labels renamed to industry-standard terminology:
Excellent/Good/Fair/Poor/Critical(wasStrong/Good/Acceptable/Weak/Critical) - Line numbers shown only for violations with precise locations (method/class level), not for file-level violations
Fixed¶
- Technical debt breakdown now calculated from all violations, not just the truncated display list
Breaking¶
- Default
--fail-onchanged fromwarningtoerror. CI pipelines relying on exit code 1 for warnings must add--fail-on=warningexplicitly
0.6.0 - 2026-03-18¶
Fixed¶
- Baseline now correctly matches file-level violations (duplication, code smell, security rules) — previously ~150 violations passed through a freshly generated baseline
- Duplicate code block locations are now sorted deterministically, making baseline entries stable across runs
- File paths are normalized to relative (vs CWD) to prevent mismatches with absolute or
./-prefixed paths
Breaking¶
- Baseline version bumped to 5 — existing v4 baselines must be regenerated with
--generate-baseline
0.5.0 - 2026-03-18¶
Changed¶
exclude_namespacesis now a universal per-rule option available for any rule, not just coupling rules
Breaking¶
exclude_namespacesforcoupling.cboandcoupling.instabilitymoves from nestednamespace:to top-level rule configexclude_namespacesnow filters violations at all levels (class + namespace), not just namespace level
0.4.0 - 2026-03-18¶
Changed¶
- Health scores redesigned: 5-tier labels (
Excellent/Good/Fair/Poor/Critical), recalibrated formulas for complexity (avg + P95 + sqrt(max) penalties), coupling (efferent-based, P95 + sqrt-scaled max), cohesion (TCC neutral value for small classes), maintainability (MI anchor shifted to 30).--exclude-health=DIMENSIONto exclude dimensions from scoring - Computed metrics: 6 built-in
health.*scores plus user-definablecomputed.*metrics via Symfony Expression Language formulas, per-level formulas, threshold-based violations - Summary-first CLI:
--format=summaryis now the default output — health bars, worst offenders, violation summary, and contextual hints in one screen - Drill-down navigation:
--namespace=App\Serviceand--class=App\Service\UserServicefor progressive filtering with auto-enabled--detail. Namespace/class health scores shown in drill-down headers - Interactive HTML report:
--format=health— self-contained D3.js treemap, health coloring, search, metric selector, dark mode. Use--output/-oto write any format to a file - JSON output redesigned: summary-oriented with
meta,summary,healthdecomposition,worstNamespaces,worstClasses,violations(top 50 by default).--format-opt=violations=all|0|N,--format-opt=top=N - New rules:
code-smell.long-parameter-list,code-smell.unreachable-code,code-smell.identical-subexpression,design.god-class(Lanza & Marinescu),design.data-class,code-smell.constructor-overinjection,code-smell.unused-private,design.type-coverage,duplication.code-duplication(Rabin-Karp token hashing),coupling.class-rank(PageRank),security.sql-injection,security.xss,security.command-injection,security.sensitive-parameter,security.hardcoded-credentials - New output formats:
--format=metrics(raw metric values),--format=github(PR annotations) - Technical debt: remediation time estimates per violation, aggregated debt in reports,
--detailshows per-rule breakdown --fail-on=erroroption to allow warnings without failing the build--include-generatedto override automatic@generatedfile skipping--disable-rule=duplicationnow skips the memory-intensive detection phase entirely (not just violations). Same for circular dependency detection- Violation messages improved: actionable recommendations, parameter names in boolean-argument, coupling direction in CBO, CCN divergence hints, top-5 dependencies in coupling violations
bin/qmx graph:export --format=json— dependency graph as aggregated JSON adjacency listcomposer benchmark:checkregression suite — validates health scores against 15 open-source projectsllms.txtandllms-full.txt— machine-readable documentation for AI coding agents
Fixed¶
- Metric algorithm corrections: cognitive complexity nesting in closures, cyclomatic complexity for
matcharms, NPath formulas aligned with Nejmeh/PMD standards, Maintainability Index class-level aggregation, WOC formula, RFC for traits/enums, abstractness formula for interfaces - Anonymous class isolation: methods inside anonymous classes no longer attributed to enclosing class (CCN, NPath, Halstead, ParameterCount, UnreachableCode visitors)
- Suppression system (
@qmx-ignore): fully wired into pipeline,@qmx-ignore-next-linescoped to single line, file-level regex fixed, symbol-level no longer leaks to file-level - Output formatters: SARIF schema compliance (paths, locations, helpUri), Checkstyle/Text relative paths, GitLab project-level path, JSON NaN/Infinity handling
- Configuration:
--confignow functional,exclude_pathsaccepted, YAML key normalization preserves rule IDs, deep merge for CLI overrides,fromArray([])applies defaults - Security rules: XSS and command injection detect superglobals in interpolated strings
- Infrastructure: cache hit skips AST traversal, runtime state reset between runs, baseline v3 migration errors, parallel worker validation
Breaking¶
--format=htmlrenamed to--format=health;--format=metrics-jsonrenamed to--format=metrics--format=summaryis now the default (wastext). Use--format=textfor the previous behavior--format=jsonredesigned — no longer PHPMD-compatible. See documentation for new schema- JSON field
humanMessagerenamed torecommendationin violation objects - Health scores: 5-tier labels (was 4-tier), recalibrated formulas — baselines may need regeneration
- NPath values changed due to formula corrections — baselines may need regeneration
- Baseline version 3 no longer supported — regenerate with
--generate-baseline
0.3.0 - 2026-03-08¶
Changed¶
- CLI command renamed from
analyzetocheck, with aliases for backward compatibility - Canonical config file name is now
qmx.yaml exclude_pathsoption for violation suppression by file path patterns- MkDocs Material documentation website (EN/RU)
- Version derived from Composer/git tag instead of hardcoded constant
Fixed¶
- LCOM4 calculation aligned with original Hitz & Montazeri specification
- Maintainability Index accuracy: use ELOC instead of physical LOC
--workers=0semantics corrected
0.2.2 - 2026-03-05¶
Changed¶
- Rule NAME constants follow
group.rule-nameformat (kebab-case) SizeRulesplit intoMethodCountRuleandClassCountRuleCouplingRulesplit intoInstabilityRuleandCboRuleRuleMatcherutility for prefix-based rule matching- ANSI colors, grouping, and
FormatterContextfor formatters - Baseline v3 format with duplicate NAME validation
- Suppression system updated for dotted rule names and prefix matching
0.2.1 - 2026-03-05¶
Fixed¶
- TTY output written line by line to prevent macOS terminal truncation
0.2.0 - 2026-03-05¶
Changed¶
- Category filtering for rules
- Default thresholds calibrated
0.1.1 - 2026-03-04¶
Changed¶
violationCodefield inViolationfor stable baseline hashing- Improved violation messages with thresholds and actionable advice
Fixed¶
- Namespace-level violation display and
minClassCountfilter
0.1.0 - 2026-03-04¶
Initial release.
- PHP static analysis CLI tool
- Metrics: Cyclomatic Complexity, Cognitive Complexity, NPATH, Halstead, Maintainability Index
- Metrics: RFC, Instability, Abstractness, Distance from Main Sequence
- Metrics: TCC/LCC, LCOM4, WMC, LOC, DIT, NOC
- Rules with configurable thresholds
- Circular dependency detection with DOT graph export
- Output formats: Text, JSON, Checkstyle, SARIF, GitLab Code Quality
- Parallel file processing via amphp/parallel
- Git integration:
--staged,--diff - Baseline support with
@qmx-ignoresuppression tags - AST caching, progress bar, PSR-3 logging
- Git hook installation (
hook:install,hook:status) - Symfony DI with autowiring and autoconfiguration
- GitHub Actions workflow