Перейти к содержанию

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.32.0 - 2026-10-30

Breaking

  • An exclude, suppress_paths or suppress_namespaces entry, or a value of a rule's suppress_namespace_channels entry, that matches nothing and that only a file in the project's Composer install directory wrote (a base imported from vendor/) is no longer reported by discovery.unmatched-exclude or suppression.*, since the project can neither edit nor remove it; it is listed in projectScope.unjudgedValues with an installed-configuration reason naming the file. Values that bind, and values the project can replace (a rule's suppress_paths/suppress_namespaces, coupling.framework_namespaces, exclude_methods, architecture.layers), are judged as before. Migration: a baseline that accepted such a finding now reports the entry as baseline.unused-entry; list it with baseline:cleanup <baseline> <paths...> and remove it with --remove=<selector> (ADR 0142).

Changed

  • imports: entries may name a built-in preset: imports: [strict] applies it from the configuration file, so the pre-commit hook and the GitHub Action, which pass no --preset, judge by the same preset as a local --preset=strict. A bare name that is not built-in is refused with the list of presets.
  • imports: entries may be {subtree: dir} or {regex: fragment} selectors over the .yaml / .yml files below the importing file's directory (same shape as suppress_paths; no glob): the matches are imported in byte order unless their own imports demand another, a selector that matches nothing is accepted, and a directory that cannot be listed or entered is refused.
  • Imported files may import in turn, ordered by C3 linearization; a cycle and contradictory precedence are refused naming the files, while a file given to --preset still cannot import. See ADR 0140.
  • Automatic discovery reads a committed qmx.dist.yaml or qmx.dist.yml when the working directory has no qmx.yaml/qmx.yml; a local qmx.yaml wins and builds on it only through imports: [qmx.dist.yaml], and a run warns when an auto-discovered local file does not import the dist file beside it. Both dist names together are refused (exit 3), as both qmx.yaml and qmx.yml are, also beside a local file. --config help names the discovered files.
  • A finding that a configured value matches nothing — discovery.unmatched-exclude, suppression.*, coupling.unmatched-framework-namespace, cohesion.unmatched-exclude-method, architecture.unmatched-exclude, architecture.unreachable-layer, architecture.empty-template — ends by naming the configuration that wrote the value (Written by configuration file "vendor/acme/std/qmx.yaml" (imported by configuration file "qmx.yaml").); finding identities and baseline keys do not change.

Fixed

  • check -v no longer lists a config_file source under "Configuration loaded from" when no configuration file was found and only a near name such as qmx.yaml.bak was warned about.
  • The write guard of check --output, --profile and --log-file, graph:export --output and the baseline file now also refuses (exit 3), before analysis, the working directory's composer.json and every PHP file the run selects, compared by file identity; qmx check src/ --output=composer.json or --output=src/Foo.php replaced the manifest or a source with the report. A check or graph:export report on standard output that the shell opened on the configuration, the manifest or a source (>>, 1<>) is refused the same way and before any other target refusal, with the refusal on stderr only; a refusal reached earlier, while the options and configuration are still being read, is still written to standard output in the requested format (#223).
  • suppress_paths, --suppress-path and per-rule suppress_paths values that name a file the run discovered but did not analyse (it failed, or was excluded as @generated), or a path beneath a directory it could not list or did not follow, are listed in projectScope.unjudgedValues instead of being reported as suppression.unmatched-path; a regex value is withheld when it matches such a file or such a directory exists (#209).
  • The HTML report tree places a file-level finding of a file that declares no single class on the namespace its JSON record names — the deepest namespace common to all the file opens, or the nearest ancestor that has a tree node — instead of on the project root (#190).

0.31.0 - 2026-10-28

Breaking

  • architecture.unmatched-type findings are identified by the type and its authored path alone (architecture.layers[0].implements[1]); the file that wrote the type, its importer, its layer index and its line no longer count, so moving the criterion into an imported file, adding a --preset or an import keeps the finding, and a symlinked root configuration no longer gives its imported criteria a different identity in each checkout directory. Stored identities change once: baseline keys, finding fingerprints, GitLab Code Quality and SARIF fingerprints. Migration: list the stale architecture.unmatched-type entries with baseline:cleanup <baseline> <paths...> and remove them with --remove=<selector>, then accept the findings with baseline:update <baseline> <paths...> --accept-new=architecture.unmatched-type (#214).

Fixed

  • duplication.clone no longer slows down superlinearly on a large constant or property table: a generated parser file drops from 1.4 s to 0.2 s of clone detection and a 16,000-token table from 54 s to under 0.1 s. Two copies of one such file get much faster (63 s to 2 s) but stay quadratic when the table's rows are irregular, and other long literals, such as return [...] translation files, still cost quadratic time because each reports one nested block per row. Reported clones are unchanged (#212).
  • check --output, --profile and --log-file, graph:export --output and the baseline file of baseline:generate refuse (exit 3), before analysis, a target that is a file the configuration was read from — the auto-discovered qmx.yaml/qmx.yml, --config, every imported file and every preset file — compared by file identity, so another spelling, a symlink or a hard link refuses alike; qmx check src/ --output=qmx.yaml replaced the configuration with the report. A configuration path naming a directory is refused as … is not a regular file. instead of a parse error (#215).

0.30.0 - 2026-10-28

Breaking

  • References inside attribute arguments (Foo::class, Foo::BAR, enum cases, new Foo(), closures) are recorded as the new dependency kind attribute_argument instead of class_const_fetch, new and the like; it joins no relations: alias, and graph export types shows it; coupling values do not change. A layer policy that admitted such references through static_access, new or another kind now reports architecture.layer-violation ("uses in attribute argument"): add attribute_argument to its relations: list. Baseline architecture.layer-violation entries for these edges change identity (stale plus new): review the new violations first, then accept them with baseline:update --accept-new=architecture.layer-violation; baseline:cleanup lists the stale entries and baseline:cleanup --remove=<selector> removes them (#196).
  • Per-rule suppress_namespaces and suppress_paths are refused at load (exit 3) for producers whose findings are all project-level — architecture.circular-dependency, architecture.layer-declaration, architecture.unassigned-class, baseline.unused-entry, coupling.unmatched-framework-namespace, discovery.unmatched-exclude, suppression.configuration — also when written with --rule-opt; the option was accepted and removed nothing. Migration: delete the option; accept known findings with a baseline where the rule allows it; for dependency cycles use the ignore_relations option of architecture.circular-dependency. An empty list or null stays accepted (#202).
  • coupling.instability judges a namespace on its own declarations instead of its subtree, like namespace CBO and distance: the judged value is coupling.instability-own, the message's Ca/Ce are own-scope, min_afferent applies to coupling.ca-own, and a namespace declaring no type of its own is not judged. The namespace option min_class_count is replaced by min_type_count (default 3, counting classes, traits, interfaces and implementing enums declared directly in the namespace); the old key is refused by name. Migration: rename rules.coupling.instability.namespace.min_class_count to min_type_count and review namespace instability baseline entries, since both the value and the population change (#199).
  • coupling.instability-own is no longer published (it was 0) for a namespace that declares no type, matching coupling.cbo-own; a computed-metric formula reading m["coupling.instability-own"] without ?? leaves such namespaces without a value — write m["coupling.instability-own"] ?? 0 to keep the old result (#199).
  • code-smell.long-parameter-list no longer judges constructors that are not value objects: code-smell.constructor-overinjection (8/12) owns them, so a service constructor with 4–7 parameters is no longer reported and one with 8 or more is reported once instead of twice. This covers constructors of classes, abstract classes, interfaces, traits and anonymous classes, in any letter case; value-object constructors keep the vo-* thresholds and a global function named __construct stays an ordinary function. Disabling constructor-overinjection (or leaving it out with --only-rule) leaves these constructors unjudged. Migration: delete @qmx-threshold code-smell.long-parameter-list on non-value-object constructors (qmx directives reports them inert), move a still-needed ceiling to code-smell.constructor-overinjection, and drop their code-smell.long-parameter-list baseline entries. Consequences to check: a service constructor with 8–11 parameters used to be a long-parameter-list error and is now only a constructor-overinjection warning, so a --fail-on=error gate no longer fails on it; an @qmx-ignore code-smell.long-parameter-list on such a constructor is now reported as annotation.unused-directive (a warning) — remove it; the parameter counts above are the defaults, and the strict and legacy presets shift by their own thresholds (#170).
  • Closures and arrow functions are named under their nearest enclosing declaration and counted within it — App\A::rows::{closure#1}, nested App\A::rows::{closure#1}::{closure#1}, in a class constant, property default or attribute App\A::{closure#1}, in a function App\f::{closure#1}; file top-level code keeps App\{closure#N} — instead of a file-wide ordinal, so adding a closure elsewhere in the file no longer renames it. Stored identities of closure findings change once: baseline keys, finding fingerprints, GitLab Code Quality and SARIF fingerprints. Baseline files are now version 15: a version 14 file still loads, but every entry keyed on a closure ({closure# in its subject) is retired — it accepts nothing, because its old file-wide number can now name a different closure — and check reports it as baseline.unused-entry. Migration: list the retired entries with baseline:cleanup <baseline> <paths...> and remove all of them in one call with baseline:cleanup <baseline> <paths...> --remove=<selector> ..., which writes version 15 and keeps every other entry; then fix the closure findings now reported as new or accept them with baseline:update <baseline> <paths...> --accept-new=<channel>. Two entries of one duplicated closure identity share one selector, which removes both. Until the retired entries are gone, baseline:update and baseline:cleanup --remove refuse (exit 3) and name the selectors; baseline:rename-channels keeps writing version 14. Do not edit "version": 14 to 15 by hand — the old closure keys would load as live keys and silently re-attach — and do not use baseline:generate --force, which re-captures the whole baseline. A version 13 file is converted by hand to version 14 only, then migrated as above (#201).

Changed

  • imports: in the root configuration file lists the files it builds on; each is a layer of its own in written order below the root (the root overrides all, presets stay below the whole chain), named with its importer in refusals, JSON source[].imported_by and -v. Entries resolve against the importing file's directory; other paths keep the working directory. Imports are flat: an imported file or a preset writing imports is refused, as are the same file twice, the root itself, a directory, a URL, a glob and a non-YAML file. Moving an architecture criterion into an imported file re-identifies its architecture.unmatched-type findings, so their baseline entries go stale (#214).
  • Refusals of a configuration file as a whole quote its name: Configuration file "x" was not found., Failed to parse configuration file "x": …, Configuration file "x" is not valid YAML format. (were Configuration file not found: x and unquoted names); -v names a --config file as written (sub/qmx.yaml) rather than by its basename.
  • architecture.circular-dependency option ignore_relations: dependency kinds or relations: aliases whose edges do not form cycles, e.g. [attribute_argument] for entity–repository cycles that exist only because a mapping attribute names the repository; unknown kinds are refused by name (#196).
  • projectScope.unjudgedChannels no longer lists suppression.unmatched-namespace once at least one of its values was judged; the values still withheld stay in projectScope.unjudgedValues.
  • cohesion.lcom: private methods reached only from __construct/__destruct through direct calls leave the LCOM4 graph with them, while callees registered during construction through closures, arrow functions or first-class callables stay; a constructor that splits its setup into private helpers no longer inflates LCOM. Values can fall, rise (when such a helper bridged two property groups) or disappear (when every graph method is an initialization helper); a helper that hands instance state to a closure or binds a property by reference stays, and no method leaves the graph of a class with an unread trait or a dynamically named own call; own-method references beyond $this->m(), self::m() and static::m() (callable arrays and strings, ReflectionMethod, calls through the own class name) keep a constructor helper in the graph but never add LCOM4 or TCC/LCC edges; TCC/LCC are unchanged (#198).
  • qmx directives accepts --no-cache, --cache-dir, --workers/-w and --memory-limit with the same meaning as in check; the option definitions are shared by check, directives, graph:export and baseline:*, so their descriptions no longer drift (#197).

Fixed

  • A preset that cannot be read, parsed or found is named as written (preset "./team.yaml") instead of as configuration file "/absolute/path".
  • An unrelated exclude entry or @generated file no longer withholds every literal namespace suppression: a stale exact:/subtree: selector in suppress_namespaces (global or per-rule) is reported unless the removed code may declare a namespace it binds, and check warns on stderr about values only the removed code left unjudged (#200).
  • A namespace suppression is no longer reported stale when its code was excluded outside the analysed autoload roots or failed to parse; any analysis failure now leaves namespace suppression values unjudged.

0.29.0 - 2026-10-27

Changed

  • qmx.schema.json — a JSON Schema of qmx.yaml for completion, rule descriptions and form validation in PhpStorm and VS Code; shipped in the Composer package and attached to every release. It offers the values of format, fail_on, architecture.coverage-gap and computed_metrics.<name>.levels. An unsupported computed_metrics.<name>.levels value is now refused with the accepted levels named.

Breaking

  • A configuration value outside a fixed list (format, fail_on, architecture.coverage-gap, computed_metrics.<name>.levels, a rule option's words) is refused with one sentence shape — "fail_on" in configuration file "qmx.yaml" must be one of none, warning, error, got "info". followed by any advice — instead of Invalid value "info" for "fail_on". Allowed values: …, Output format "x" is not one of: … and architecture.coverage-gap: must be one of …. In a JSON refusal its position is now closed, with the value in written (it was the key) and the words in accepted; coverage-gap was already closed and now lists its words in their documented order rather than alphabetically. Match on position, not on the sentence.

0.28.2 - 2026-10-25

Fixed

  • code-smell.long-parameter-list no longer gives value-object thresholds to a readonly service: a readonly class counts as a value object only when it exposes some promoted property (publicly readable, or returned by a method as return $this->name;). A readonly class whose injected dependencies are all private now gets the standard thresholds (4/6 by default); expect new findings on readonly services, and give an intentional one a @qmx-threshold code-smell.long-parameter-list ceiling.
  • code-smell.constructor-overinjection no longer judges value-object constructors, whose parameters are fields rather than injected dependencies; code-smell.long-parameter-list judges them, and the JSON report lists them under abstentions with the gate value-object-constructor. A @qmx-threshold code-smell.constructor-overinjection on a value-object constructor is now inert (qmx directives reports it): remove it.

0.28.1 - 2026-10-25

Fixed

  • The project scope line names at most three skipped selectors instead of all of them.
  • Repeated class names in "Worst classes" show their declaring file and ordinal. File names in these suffixes use canonical escaping to distinguish literal # characters from declaration ordinals.
  • A namespace or class that falls short of a population minimum because a count is zero is reported as below the minimum, not as missing a metric.
  • Files with deeply nested expressions are cached; warm runs no longer warn about a discarded cache entry on every run.
  • File-level findings carry every namespace their file opens, so --namespace filters, text report grouping and JSON namespace fields retain findings in files that declare no class.
  • RFC counts an enum's built-in cases() and a backed enum's from() and tryFrom() as its own methods instead of external responses.
  • NPath counts the no-match path of a switch without default, as it does for an if without else.
  • code-smell.empty-catch no longer exempts a prepared-value guard whose direct if then-branch is only continue; before later work in the same try.
  • A failed --clear-cache names the directory and what it kept.
  • --rule-opt addressing a key inside a map option explains that the map belongs in qmx.yaml.
  • --fail-on with an unknown value no longer mentions info.
  • An ambiguous --class selection names the files that can select one declaration.

0.28.0 - 2026-10-21

Breaking

  • Path and PHP-name selectors no longer accept bare strings or implicit globs. Use {exact: value}, {subtree: value} or {regex: fragment} in YAML and exact:value, subtree:value or regex:fragment in CLI options; regex fragments match the whole canonical path/name. This covers exclusions, suppressions, framework namespaces, Distance namespace inclusion, report namespaces and graph filters. Translate old globs by intent; rule/channel X.* selectors and Architecture patterns have separate grammars. See ADR 0077.

  • Configuration accepts declared snake_case, kebab-case and camelCase spellings only, at every documented depth. Previously folded variants such as Fail_On now refuse, as do two spellings of one key in one map. Use the documented spelling and keep one write per canonical key.

  • Every authored layer is validated before discovery, including invalid values later overridden or belonging to disabled rules. Previously ignored unknown keys, malformed shapes, negative rule counts, invalid selectors, invalid formulas and conflicting same-layer writes now exit 3. Correct the named source rather than relying on an overlay to hide it.

  • Configuration maps now merge by declared leaves, while replacement lists replace whole lists. Empty maps and null bodies preserve lower values instead of resetting defaults; a rule written true changes only enabled. computed_metrics merges metric by metric and field by field, and an inherited enabled: false survives formula/threshold overrides. Write desired defaults explicitly, or enabled: true to restore a disabled metric. See ADR 0086.

  • Threshold shorthand expands within each layer before merging. Flat CBO/Instability bands no longer discard nested class/namespace settings: overlapping shorthand and explicit leaves in one layer refuse, while later layers replace only their written leaves. Use separate layers or explicit bands; unrelated level settings survive.

  • Top-level complexity shorthand now changes the callable band while retaining the class band; writing an NPath class band activates it. Write class.enabled: false to keep class reporting off. Effective warning/error ordering is validated across defaults and all layers; provide a coherent final band.

  • An exact enable in a later layer can reverse an earlier disable, and a same-layer exact enable takes precedence over a group disable. Contradictory exact enable/disable writes refuse even if overridden later. Remove the decisive enable when the producer must remain off.

  • An effective only_rules filter admitting no live rule/level, a dead exact selector, or an explicit enable outside its effective only filter now refuses instead of silently selecting nothing. Widen the filter, remove the dead selector, or move intentional narrowing to a later layer. The former both-disable-and-only warning is replaced by these decisions.

  • A bare rule-group prefix such as complexity no longer selects a group. Write complexity.*; unknown producers/channels and unsupported level selectors refuse with a replacement hint. Namespace-channel suppressions must name a channel its own producer publishes at namespace level.

  • architecture.unassigned-class uses ordinary enabled plus its mode. An explicit enable with mode: ignore refuses; choose warn/error or remove that enable. Disabling any producer also skips its preparation and inspection.

  • CLI aliases and --rule-opt now use the same declared YAML scalar/list grammar and reject duplicate writes to one option. Null is no longer converted to string text. Put channel-keyed suppression maps in YAML; dotted CLI addresses traverse declared levels only.

  • LCOM method exclusions no longer split CSV strings. Use a YAML sequence or --lcom-exclude-methods='[getName, getDescription]'; matching is case-insensitive and covered runs report unmatched names through cohesion.unmatched-exclude-method. Correct or remove unused names.

  • fail_on: false is replaced by fail_on: none. Rule threshold/count options reject negative values, integer options reject fractions, and duplication admission counts must be positive. Correct the indicated value; computed-metric thresholds still accept either sign.

  • Automatic configuration discovery now accepts exactly qmx.yaml or qmx.yml and refuses when both exist. Rename case variants, remove the duplicate, or pass --config; an unlistable search directory also refuses instead of implying no configuration.

  • Omitted analysis paths now use production autoload PSR-4, PSR-0, classmap and files targets, rather than PSR-4 production/development roots. Add --include-autoload-dev/include_autoload_dev: true to include development code, or name paths. Targets inside vendor, node_modules or .git are omitted with a reason; name an inner file/directory explicitly to analyse vendored code.

  • Missing input paths, explicitly named regular non-PHP files, paths outside the project root, and paths naming a vendor, node_modules or .git directory now refuse with exit 3. Choose the intended --working-dir, a PHP file, or a directory inside the excluded dependency root.

  • Explicitly excluded input paths now produce a successful intentionally empty run instead of a refusal or silent inconsistency. check/directives exit 0, graph export writes an empty graph, and baseline generation writes an empty baseline with an explanation. Inspect coverage before interpreting exit 0 as measured code; remove the exclusion to analyse it.

  • Skipped unreadable directories/files, non-regular entries and discovered symlinks now make analysis incomplete (exit 4) instead of a clean result. Failure kinds include directory-symlink, file-symlink, not-regular-file, unreadable-directory and unreadable-entry; unreadable source uses unreadable-file, not parse. Handle unknown failure kinds as incomplete, fix access or explicitly exclude the entry; name a file symlink directly to analyse its target.

  • Project scope now distinguishes covered, narrowed, unknown and unmeasured. A manifest-less subset or surviving fragments of a damaged manifest no longer judge whole-project absence; name the whole root or repair Composer metadata. Accept projectScope.reasons, optional project-relative omitted-root paths and unjudgedValues[].channel; unjudgedChannels means no value of that channel was judged. uncoveredAutoloadTargets lists declared targets, not individual files. Directive audit adds scope.project_scope; exclusions/generated removal can withhold namespace absence even when paths otherwise cover the project. Scope warnings now follow analysis on stderr; do not use their former ordering as a progress signal.

  • Graph export now follows configured paths, presets, exclusions, generated-file policy, cache, workers and memory limits. Review results formerly produced outside that policy. Use --direction for graph direction: global -d selects the working directory; graph --format remains dot|json. With no PHP files it now publishes an empty graph and exits 0 instead of exiting 1; status text moves to stderr so stdout contains graph data only.

  • Graph namespace filters, including --exclude-namespace, now refuse unbound selectors with exit 3 against the original graph. Correct the selector or use the exact observed spelling; a case-only suggestion is not acceptance.

  • qmx rules now validates the configuration document and selection instead of succeeding beside malformed input. Repair the named layer; the listing shows accepted option forms and configured computed metrics, but does not validate effective rule bands by executing them.

  • Unknown, partial, namespace-only and differently cased command names now refuse with exit 3. Use exact command names. Empty/blank destination and configuration options, empty preset names, invalid detail/top/group/profile-format values, unknown format options and contradictory formatting caps also refuse instead of falling back; omit an option for defaults or supply its documented value.

  • graph:export --workers no longer coerces invalid values. Supply a non-negative decimal integer within PHP's integer range; 0 means sequential and omission retains configured automatic selection. Memory limits exceeding PHP_INT_MAX, invalid Windows NUMBER_OF_PROCESSORS, empty --working-dir and empty baseline:explain --channel also exit 3; correct or omit them.

  • Internal errors now exit 5, incomplete analysis exits 4, and configuration/environment refusals exit 3 instead of being confused with finding exits 1/2. Baseline lock/read/conflict failures, unresolvable Git revisions, hook refusals and unavailable Git process support follow exit 3 with stderr reasons. Update CI routing; reinstall generated hooks with qmx hook:install --force.

  • bin/qmx now exits 3 on PHP older than 8.4 or without its autoloader instead of reaching a parse/bootstrap failure. Upgrade PHP and install dependencies before running it.

  • JSON refusals now carry {error, exit_code, position, source} instead of only error/code, including refusals before command execution. Accept nullable document positions and source-layer lists; CLI positions remain null and sources retain the authored option. Storage failures identify an environment source.

  • -q, --silent and SHELL_VERBOSITY=-1|-2 no longer hide a command answer or terminal error. Redirect stdout when only the exit status is needed; quiet still controls incidental status output.

  • QMX_ASCII accepts case-insensitive 1/true/yes/on and 0/false/no/off/empty/absent; other values refuse before commands with environment exit 3. Its closed glyph substitutions also affect matching sequences in source names. Select Unicode mode to preserve those glyph sequences.

  • The default AST cache moves from checkout .qmx-cache to the user's shared qmx cache directory; old entries are not read. Docker defaults to /cache. Update cache mounts/CI configuration and let the cache warm; use a separate volume per uid.

  • Explicit --cache-dir/cache.dir roots inside the checkout, owned by another user, or writable by group/others now refuse with exit 3 when caching or clearing uses them; an unusable implicit default disables caching with a warning. Move the root outside the checkout and correct its permissions. --no-cache without clearing does not judge an unused root.

  • AST entries use a new allowlisted codec instead of igbinary; old formats become cache misses and ext-igbinary is no longer suggested. --clear-cache removes only qmx-owned entries, across every project/worktree sharing that root, after all invocation checks pass. Rerun a valid invocation to clear the cache and retain foreign files separately.

  • --clear-cache with foreign files inside a versioned cache directory now retains that directory as .clearing-* and exits 3 before analysis; a foreign file directly in the cache root is preserved and does not prevent success. It previously printed Cache cleared, ran analysis and exited 0 or 2 according to findings. After a refusal, recover or remove the foreign files from the named .clearing-* directory, or configure a dedicated cache.dir/--cache-dir, then retry.

  • Parallel collection uses worker processes only; ext-parallel transport is removed. For an eligible explicit worker request, failed startup/first probe now exits 3 instead of silently running sequentially; automatic selection falls back with a warning. A startup refusal may take up to ten seconds. Configure --workers=0 for sequential execution.

  • Worker output is captured instead of passing raw stdout/stderr through machine-report streams. Missing or invalid registered collector/traversal types now produce one coordinator internal error before startup; custom collectors need the parallel-safe marker or sequential execution. Consume coordinator diagnostics rather than parsing raw worker output.

  • Coordinator/duplication memory exhaustion now exits 4 with memory-limit guidance instead of raw fatal exit 255. JSON formats retain an error envelope. Treat the run as incomplete and adjust the limit; shutdown does not publish a new profile or leave newly claimed final output names empty.

  • Regular report, profile, graph and baseline outputs now publish atomically after analysis instead of exposing a new empty final file or partial replacement. Successful writes replace the inode and preserve existing mode; reopen the final pathname. Parents must already exist and permit a sibling; absent-name publication requires hard-link support, so precreate a destination where unavailable. See ADR 0096.

  • Output symlinks require a parent controlled by the invoking user/root or a proven private primary group. Replaceable FIFO/device targets, /dev/stdin, unsupported URI schemes, and distinct report/profile/log names resolving to one recipient now refuse. Use separate destinations and ordinary paths, file:// or supported stdout/stderr/fd descriptors; shell 2>&1 and descriptor append remain supported.

  • Profile publication occurs after the run and before the report; write failures exit 3 through the error envelope. A later failure does not roll back an already published profile. JSON profiles are always {"spans": [...]}, spans add stopped and remove misleading peak_memory_delta_bytes; update readers and ignore unfinished spans in totals.

  • hook:install generates a regular hook instead of a symlink to the removed scripts/pre-commit-hook.sh. Run qmx hook:install --force to replace old hooks; hook:status reports outdated templates. Backup restoration consumes the backup path by moving its inode; retain a separate copy if needed.

  • Baselines move from v13 to v14; older versions refuse. Retain v13 entries, scope and generated time, change version to 14 and add exclusions: {patterns, generated} matching the original acceptance population. Follow the baseline migration guide; older identity formats need reviewed remapping, not wholesale regeneration to erase history.

  • Baseline documents now reject unknown envelope/entry/edge/exclusion keys before analysis, including misspelled magnitude keys. Fully recognized canonical input also rejects repeated envelope/subject keys; entry objects and noncanonical input retain native last-member behavior. Repair named keys/duplicates before loading or channel renaming.

  • Baseline ceilings now compare complete compatible groups, rather than treating every present accepted level as a breach. JSON/HTML add baselineVerdict and baselineReason; read them alongside acceptedLevel. Narrow runs retain unmeasured/outside/not-compared entries; present source-owned layer occurrences can compare in a source-only hook. Run over the required population before tightening absent entries; --show-resolved counts comparable absence only, while stderr summaries distinguish unmeasured/outside/not-compared/unselected entries.

  • Stale/inert baseline entries now produce baseline.unused-entry warnings, including Git reports and runs with ordinary suppressions; undeclared levels are inert level-not-declared. A missing file below a positively present recorded root can be stale even in a narrow run. Review moves and repair/remove selected entries rather than assuming every unmeasured entry is resolved.

  • baseline:update preserves recorded scope and leaves unchanged entries unwritten. --accept-new requires ordinary recorded-scope coverage unless forced; --force cannot supply missing comparison evidence. Scope refusals now exit 3 on stderr. Analyse at least the recorded scope, then explicitly name channels for new acceptance.

  • baseline:cleanup lists candidates read-only and removes only explicit --remove=<selector> selections. A selector shared by inert duplicates removes every contender of that identity; other collisions refuse. baseline:explain now prints separate baseline/now: states and inert/unreadable reasons; update prose parsers rather than treating not-measured as stale.

  • Unknown baseline entry fields no longer disappear during rewrites. A malformed object-shaped subject bucket keeps its container; mixing a valid entry at that portable subject refuses. Remove that bucket explicitly before accepting the entry. Exclusion changes require --record-exclusions, exact recorded paths and complete proof; inspect all named outcomes, including exclusions-removed-population when a present PHP source is newly excluded, before publishing.

  • Canonical subjects now escape path % as %25 and declaration-file # as %23. Fingerprints/selectors and affected baseline identities change. Review unmatched entries, migrate only selected identities and refresh GitLab/SARIF fingerprint consumers; raw non-UTF-8 scope/selectors refuse, so analyse a containing UTF-8 directory.

  • Class/callable metrics and HTML links now identify exact declarations instead of collapsing same-name bodies, including duplicates within one file. Key consumers by canonical subject, permit repeated display names and regenerate saved HTML links. Namespace/project records carry file: null and line: null; class-derived samples count declarations, while graph algorithms still use logical names.

  • JSON findings/top issues and HTML now share rule, code, symbol, exact identity, baseline judgement and other finding fields; accept channel, edge, threshold, techDebtMinutes and acceptedLevel, and replace HTML ruleName/violationCode/symbolPath. message is the diagnostic and recommendation is separate, including suppressed output. Circular-dependency recommendations drop their embedded JSON debug dump; consume structured evidence. Accept namespaces; singular namespace is "" for global, a sole namespace, or null for multiple/project membership.

  • Project findings no longer use the __PROJECT__ namespace sentinel: singular namespace metadata is null, grouping labels use (project), and fileless prose uses [project]/(global). Treat these as aggregate labels; a real namespace named __PROJECT__ remains a namespace and namespace suppression cannot hide project findings.

  • JSON violationGroups keys are fully percent-encoded instead of display spelling. Decode dictionary keys with rawurldecode; use canonical subjects or SARIF URIs for byte identity. Invalid source bytes display as %XX, not U+FFFD, and every format reports repairs on stderr; GitLab/Checkstyle no longer invent repair findings.

  • Namespace findings no longer borrow a synthetic source file; move intended namespace exceptions from suppress_paths to suppress_namespaces. GitLab/Checkstyle now retain fileless findings through real namespace declaration/directory, baseline-file or project locations. Accept those location-bearing records and use JSON/SARIF for their full structured evidence.

  • --format=text-verbose and format: text-verbose are removed. Use --format=text --detail=all. Finding caps now select severity then impact before grouping, so recheck snapshots depending on original rule order; unprocessed-file prose is limited to ten names plus a remainder count.

  • --namespace/--class drill-down retains the whole-run exit verdict and exposes outside-selection counts. GitLab, Checkstyle and suppressed formats refuse these selectors; drop the selector or use JSON/SARIF/GitHub/HTML. Namespace drill-down includes file aggregates through measured file membership; class selection does not include file aggregates. JSON/metrics always include outOfScope (null without a selection), with exact outside-finding identities; HTML/SARIF/GitHub expose a corresponding notice.

  • Suppressed output adds exact subject, occurrence, edge, coverage and whole-run selection composition. Findings removed by selection use mechanism selection; producers never executed appear separately in notRun, not in finding counts. Parse those states separately; --show-suppressed remains whole-run beside drill-down.

  • HTML uses published metric bags rather than synthetic health defaults, replaces summary.healthScores with published subtree scores and project summary.healthDecomposition, and uses size.class-loc for class-like area. Handle missing health as unknown; do not substitute project health for absent selected-class/namespace values.

  • Worst-offender rank-by=count is replaced by score|density, default score, over the full captured population before limiting. Replace worstNamespaces[].size.class-count with size.class-count.sum, healthScores.overall with healthOverall, and ranking coupling.class-rank with coupling.class-rank-share. Namespace eligibility uses own classes/interfaces/traits/enums; review ranking snapshots.

  • Successful check JSON adds configurationDiagnostics, computedMetricOutcomes, abstentions and inheritanceDepthOutcomes, including empty arrays. These are distinct from findings; consume missing-input, unjudged-population and incomplete-inheritance metadata without changing the finding verdict. Prose, HTML and SARIF expose bounded equivalents; strict schemas must admit them.

  • size.loc no longer counts a final newline as an extra line, and punctuation beside comments counts as code. Recheck LOC-derived aggregates and limits. Namespace size.{class,abstract-class,interface,trait,enum,implementing-enum,function}-count.count contribution counters are removed; read each declared count directly.

  • size.class-count judges a namespace's own classes instead of its subtree total. size.class-loc now includes interfaces/traits/enums in exports and aggregates, and method-less named class-like declarations publish complexity.wmc: 0. Review class-count/area/WMC expectations and accepted findings.

  • Accessor counts and design.woc require both a get/is/has/set name and a simple property-read/write body. Computed/forwarding methods now count as behavior; size.method-count.total still counts every method. Recheck method limits and WOC expectations.

  • Cognitive complexity now follows SonarSource v1.7 for nested ternaries, lambda nesting, recursion, boolean sequences and else if; coalescing contributes nothing. Resolved aliases/case variants count own recursion, foreign calls do not. Reanalyse and review thresholds/accepted magnitudes instead of carrying old scores forward.

  • NPath now gives ??/??= value-selection paths (for example return $x ?? 5 is 2 instead of 1), counts branches inside bare/declare blocks and expressions in unset/static/global statements, and saturates at 1,000,000,000 as a lower bound. Reanalyse accepted magnitudes; do not interpret saturation or > 1M (extreme) display as an exact count.

  • Cohesion uses effective methods, resolvable traits/adaptations, PHP-insensitive call names and instance-helper property access. Lifecycle names are excluded after composition; uncalled stateless constant methods merge, called ones retain vertices. An empty eligible LCOM graph has no value instead of 0. Recheck cohesion findings, treat missing values separately and inspect cohesion.unread-trait-count for incomplete composition; full-run evidence is needed for whole-project comparison.

  • LCOM min_methods now counts eligible graph vertices instead of all methods. Health cohesion uses its tracked TCC population for purity and preserves that correction in namespace/project formulas, whose LCOM span changes from two to five; aggregate formulas use average inputs, not average class scores. Review custom minimums, health thresholds and accepted findings after recalculation.

  • RFC now uses resolved response targets on exact declarations, including inheritance/parent calls, effective trait aliases, abstract methods and property hooks, with PHP case folding. Review saved coupling.rfc, coupling.rfc-own and coupling.rfc-external expectations and accepted findings.

  • DIT resolves external ancestry by parsing the analysed project's Composer sources rather than loading classes through the tool's autoloader; registered PHP ancestry is followed transitively. Exact declaration depths feed aggregates; NOC counts distinct logical child names. Recheck depth/child limits, especially polyfills and builtin descendants. Interfaces/traits/enums no longer publish class DIT/NOC.

  • Incomplete ancestry publishes design.dit-unresolved; cycles and descendants omit numeric DIT instead of inventing a loop depth, and floors say “at least N”. design.is-exception follows Throwable, omits unknowns and no longer publishes 0 on interfaces. Treat absence as unavailable; DataClass with exclude_exceptions: true skips unknown ancestry, or set it false to judge other criteria.

  • Dependency collection now includes class/enum-case attributes, typed constants, property-hook bodies, nested named functions and user types named Integer/Double/Boolean. Promoted property edges use property_type, constants use constant_type; union_type/intersection_type relation kinds are removed and type_reference groups the three position kinds. Graph edges add an always-object shape map from positions to shape lists. Update relation filters and shape readers, then review new graph findings.

  • Coupling excludes all registered PHP builtins regardless of case and merges logical class/namespace case variants deterministically while retaining exact declarations. Values can fall for newly recognized builtin names; review coupling, layer assignments and accepted findings. Authored layer criteria remain case-sensitive for project/vendor names.

  • Namespace CBO publishes subtree coupling.cbo and own-scope coupling.cbo-own; its rule and minimum class count now judge own scope. Replace project coupling.distance.avg/count with coupling.distance-own.avg/count, weighted by eligible own type counts across all type-declaring namespaces. Review aggregate/health expectations and consumers of the old keys.

  • Distance judges own namespace D/A/I/Ca/Ce and replaces min_class_count/min-class-count/minClassCount with min_type_count. Abstractness is absent without eligible types, and distance is absent without both applicable inputs and any Ca/Ce; do not substitute zero or the former disconnected D=1.

  • ClassRank retains raw PageRank as coupling.class-rank but judges classes by coupling.class-rank-share (probability × graph vertices), with fixed 5/10 defaults. Review limits/baselines in share units; interfaces, traits and enums still affect the graph denominator, and ranking no longer falls back to raw probability.

  • Project health coupling uses outgoing Form B inputs (coupling.ce-packages.avg, scaled coupling.ce.avg, coupling.ce.max, coupling.distance-own.avg) instead of bidirectional CBO terms. Incoming popularity alone no longer incurs an outgoing penalty. Recompute saved scores and accepted findings; the breadth term contributes zero below its floor of four.

  • Health dimensions/type percentages omit inapplicable values instead of publishing synthetic 75/100/0 scores. Raw typed/total counters retain measured zero; a percentage of zero still requires a positive denominator. Overall combines available dimensions. Coverage distinguishes measured/not-measured/not-applicable and custom constants cannot borrow builtin evidence. Handle null/absence before comparing scores.

  • Health exclusion removes whole ordered weighted_mean terms without rounding or pre-normalization. Migrate custom overall sum/product formulas to the supported canonical weighted-mean shape when using dimension exclusions, or handle available inputs explicitly. Invalid reached formula inputs/results now refuse with exit 3; impossible metric references at a declared level refuse before analysis instead of failing internally. Correct the metric/level or write a genuine fallback; authored missing/null results remain successful absence.

  • Layer violations now belong to the exact source declaration and physical dependency site, with occurrence identity based on source, logical target and dependency kind. Move outgoing-edge directives to the source; excluding/changing only the target does not remove the source violation. Review cleanup selectors, accept new source groups and explicitly remove selected old entries, preserving other ceilings.

  • Layer declaration diagnostics now come from architecture.layer-declaration, whose configuration validators remain active under unrelated selections. Disable that producer or architecture.* to stop them. Active unassigned/coverage-gap modes reject an empty layer list; declare layers or choose ignore.

  • Layer attributes now matches the class-like declaration; use member_attributes for own members. Unresolved ancestry is undecidable rather than a non-match, with architecture.doubted-assignment metadata and more cautious unreachable/exclude/shadow diagnostics. Review assignments and widen paths where needed; implicit enum/Stringable interfaces and parsed vendor ancestry participate.

  • Templates using match: any with non-pattern/captureless criteria now refuse instead of globally claiming every instance. Use match: all or a static layer. debug:layer-assignment rejects unknown names, canonicalizes observed identities and adds policyDisabled, edgeEndOnly, chainStopsAt, contendingMatches and contenders; adapt JSON consumers.

  • Inline tags must start their own physical comment line after decoration; quote literal examples with paired equal backtick runs or a closed fence. Mid-line/misspelled/channelless tags now diagnose instead of silently suppressing; write a channel or explicit *. Unclosed quoting fences also refuse directive-shaped content.

  • Member ignore directives affect that member's lines, while methods/hooked properties/promoted parameters retain their documented reach. Unreachable explicit levels and fractional integer thresholds now diagnose; repeat intended member exceptions and use whole-number limits. Direct closure argument/array/return/assignment forms bind; nested wrapped closures retain closure-not-direct-value refusals.

  • Annotation locations now use the tag's physical byte-positioned line, not the docblock opening; update line-based consumers. Blanket unused controls can report unused, whose default severity rises from Info to Warning; remove inert controls or set rules.annotation.directive.unused-directive-severity: info to retain the former warning exit policy.

  • Directive audit replaces old unmeasured/already-refused/every-channel states with refused plus refusals[]. Exit 2 is a publishable refusal, exit 4 takes priority for incomplete analysis; otherwise it exits 0. Read decisive disabling statements only, and disable annotation.directive explicitly when needed; unsupported/invalid thresholds follow their addressed rule.

  • Duplication now publishes each copy as file:<path> with covered-code-line magnitude instead of a project finding. Use duplication.clone:file, baseline:explain file:<path> and path suppressions; remove selected old project entries and accept current copies. Balanced segmentation/token normalization can change occurrences and fingerprints, so review them once.

  • Duplication removes warning, threshold and local threshold overrides; use positive min_lines, min_tokens and error (defaults 5/70/50), or disable the rule. Each admitted copy is Warning below its own error boundary and Error at/above it. Recheck limits against token-covered lines rather than enclosing spans.

  • Named code-smell/security findings now carry their exact declaration symbol and namespace instead of a file symbol. Namespace grouping and impact attribution follow that declaration; recheck grouped/ranked reports. File/anonymous evidence retains file identity.

  • Credential detection now judges uppercase dotted values and broader credential names/assignments; debug calls are no longer exempt because their containing method is named dump/debug. Review new findings and add a reasoned exact ignore only for intentional cases.

  • Git reports filter physical findings by changed source file; non-strict mode also retains relevant aggregates, while declared project-scoped diagnostics survive both modes. Empty range endpoints mean HEAD, POSIX backslashes remain literal, and Git ≥2.28 is required. Update Git installations and path/exit consumers.

  • Finding's executable selection services move from Qualimetrix\Analysis\Finding\Contract\Selection\ to Qualimetrix\Analysis\Finding\RuleConfiguration\Selection\: update consumer imports for RuleEnablementResolver and RuleNameJudge. Run contracts move out of Qualimetrix\Analysis\Run\Contract\Pipeline\: AnalysisCoverage, AnalysisFailure, AnalysisFailureKind and IncompleteAnalysisException move to Qualimetrix\Analysis\Run\Contract\Coverage\; DependencyGraphAnalysisResult and DependencyGraphAnalyzerInterface move to Qualimetrix\Analysis\Run\Contract\DependencyGraph\; DirectiveAuditInterface and DirectiveAuditReport move to Qualimetrix\Analysis\Run\Contract\DirectiveAudit\. The old namespaces have no aliases; replace imports directly. See ADR 0126.

  • PHP integrations move to subject-owned contracts and exact declaration identities; retired facades/concrete construction surfaces have no aliases. Update imports, service composition and copied result metadata using the migration sections in ADR 0022, ADR 0092, ADR 0093, ADR 0094, ADR 0099, ADR 0106, ADR 0108, ADR 0124, ADR 0125, ADR 0128 and ADR 0129, rather than retaining intermediate APIs.

Changed

  • The composer package carries only what qmx runs: src/ without its module notes, bin/qmx, the four HTML report assets, composer.json, LICENSE, README.md and qmx.yaml.example (7.1 MB → 5.7 MB). The repository's own configuration, ratchet, lock file, changelog and contributor notes are no longer installed into vendor/.
  • Standalone qmx.phar ships with releases. Keep the .phar suffix to avoid copying the archive for parallel startup; generated hooks work with Composer installs, source checkouts and the phar.

  • Shipped HTML assets move from src/Reporting/Template/ to html-report/; update direct asset paths. Ordinary HTML generation needs no configuration change.

  • The Docker image includes pcntl for coordinator interruption cleanup, and running it without arguments prints usage. Mount /cache to preserve AST entries between runs.

  • AST entries unused for 30 days are swept incrementally, with a 2 GiB target per format plus intervening writes and one oversized newest entry per shard. Fresh publication temporaries are retained; their bytes are outside a hard quota guarantee. Parser version/commit and short_open_tag participate in keys, and short-tag configuration reaches workers.

  • Automatic worker counts respect container CPU quotas, reducing host-sized worker pools; --workers=N overrides the detected count. -vv identifies actual parallel startup and profiles include collection.workers.

  • baseline:update --accept-new=<channel> accepts only comparable new identities for named channels while preserving existing payloads. --record-exclusions records reviewed exclusion changes and recaptures eligible affected groups; these modes cannot combine.

  • baseline:rename-channels reports declared_rows, distinguishes a map declaring no renames, keeps JSON rows/unreadable as objects and preserves unusable entries. No actual rename means no write.

  • Namespace/project exports add cohesion.pure-method-count.avg/count and cohesion.tcc-method-count.avg/count; namespace coupling also exposes coupling.ca-own, coupling.ce-own, coupling.instability-own, coupling.abstractness-own and coupling.distance-own, with project size.symbol-declaring-namespace-count as coverage population.

  • Health reports now display evidence coverage beside scores; coverage uses the selected formula inputs and type-declaring namespaces. Finding/outside-scope counts remain visible even when no health score applies.

  • architecture.layer-overlap reports partial non-pattern precedence losses at Info; named shadow exemptions preserve intentional ordering. architecture.unmatched-type reports each unmet authored type at Warning only with complete declaration/install evidence; withheld judgement is explained.

  • Configuration diagnostics name original keys, paths, positions and contributing layers in precedence order, including effective-band defaults and inherited formulas. A standing only_rules: [] overlay warns that it lifts a lower filter.

  • qmx rules lists root/level option forms separately from dedicated CLI aliases and shows actual selection writers. Unknown keys and selectors receive vocabulary-aware suggestions.

  • JSON envelopes, SARIF metadata, HTML and prose reports now link to the documentation site and agent index; SARIF informationUri points to documentation. Graph timestamps use UTC consistently with other reports.

  • Log level configures file output and the verbose console; without verbosity, selecting debug logging leaves ordinary console output at warnings. Console markup in messages is shown literally.

  • Higher-is-worse messages distinguish reaching a boundary from exceeding it and increase precision when rounded values would look equal; raw comparisons and inclusive boundaries are unchanged. ClassRank advice labels direct dependents separately from transitive PageRank influence.

  • Inline-directive channel suggestions now sort equal edit-distance matches lexically, giving consistent advice across registrations.

  • Built-in health rules accept an exact class-level @qmx-threshold boundary, so an intentional class design can keep a finite limit without a baseline entry. Use a single numeric boundary (or equal warning/error values); namespace and project thresholds stay configured in computed_metrics. User-defined computed channels still do not accept inline threshold overrides.

Fixed

  • Rule execution and metadata use lexical rule-name order instead of source-file registration order, with built-in health rules retaining their separate append sequence; baseline:explain sorts channels by code and preserves occurrence order within each channel. Producer names are unchanged; refresh order-sensitive snapshots.

  • Narrow runs publish declared uncovered autoload targets instead of every missing PHP file, keeping scope explanations bounded by configured targets.

  • GitLab Code Quality and Checkstyle no longer drop namespace/project/unused-baseline findings counted by the exit status; their published locations identify real declarations, directories or an in-project baseline. Directory anchors, including external-baseline/project fallbacks, do not provide source-line links in the GitLab UI.

  • Rule-population warnings now identify actual missing evidence; deliberately ineligible subjects and architecture rules without configured layers do not create artificial incomplete-population warnings; JSON abstentions[].dataGap identifies real missing evidence.

  • Conflicting trait methods without a resolving adaptation now fail that file with incomplete-analysis exit 4, name its class and preserve the remaining report. Ambiguous --class selection now refuses with exit 3 and both declarations instead of an internal error; when both declarations share one file, analyse that file without --class or rename one declaration.

  • With ext-pcntl and available SIGINT/SIGTERM ownership, signal-interrupted parallel runs remove worker startup files, including stdout-only runs. Coordinator memory exhaustion also removes them. Killed workers no longer incur a second ten-second wait for an absent shutdown note.

  • Cache sweeping/clearing recognizes publication temporaries and retries retained detached directories. Corrupt/foreign AST entries reparse, and --no-cache/cache.enabled: false actually prevent reads/writes.

  • Refusals for retired fail_on: false and --format=text-verbose inputs now name their fail_on: none and --format=text --detail=all replacements.

  • Integer refusals suggest the actual whole-number spelling, and baseline cleanup reports every removed physical entry, including duplicate identities.

  • Source-only hooks compare present accepted layer occurrences without requiring target-file coverage, while absent run-wide entries remain unmeasured.

  • Cohesion health aggregation preserves the class purity correction for namespaces/projects, including a namespace containing one class. Namespace LOC preserves integer sums and folds each file contribution once.

  • Cognitive recursion resolves aliases and namespace-relative names; NPath retains branching inside bare/declare blocks and static/unset expressions. ??= contributes a CCN decision, and Maintainability Index no longer fabricates missing CCN.

  • Formula evaluation no longer lets an independent missing key hide a reached invalid value. Unselected branches stay unjudged; missing inputs no longer become arithmetic zero.

  • DIT aggregates use resolved cross-file depths; Composer classmaps with nested vendor roots resolve correctly. Malformed/unavailable external ancestry is diagnosed without executing analysed code or ending the entire run.

  • Nested anonymous classes no longer supply inheritance, children or membership attributes to their enclosing named class. External enums retain implicit interfaces and layer member attributes exclude nested anonymous declarations.

  • Namespace case variants share a complete metric bag; global namespace offender counts/density include their own findings without borrowing named namespaces.

  • Hooks respect core.hooksPath and linked worktrees, quote binary paths safely, read staged names NUL-delimited, fail closed on enumeration failure and preserve the real qmx exit status. Failed execution no longer produces baseline advice.

  • Forced hook installation preserves third-party backups, refuses to overwrite a different backup and reports missing/unreadable links distinctly. Installed binary hints and relative-path installation now work.

  • Git scope handles non-ASCII names, type changes and unresolvable revisions correctly; subprocess stream draining avoids a locator deadlock. Refusal text containing console markup remains intact, including JSON envelopes.

  • HTML retains every counted finding, including project/file/global-function findings, names the analysed project and safely embeds names resembling template placeholders.

  • SARIF percent-encodes raw path bytes, omits empty related locations and uses channel-specific descriptions. Structured reports and directive/debug output survive malformed identifier bytes without losing canonical identity.

  • Detailed text/summary caps follow the complete sorted list, and drill-down no longer claims a clean whole run while outside findings decide a failing exit. Baseline comparisons decode canonical %/# subject paths correctly.

  • Unused-private detection respects case-insensitive magic/own calls, imported names, literal callable arrays and anonymous-class receiver boundaries; a private method called only by itself remains unused.

  • Debug return-mode exemption applies only to print_r/var_export; fully qualified \exit()/\die() and mixed-case constructors are recognized. Unreachable-code checks nested statement lists and identical-subexpression follows both elseif spellings.

  • Empty-catch exemption requires successful work in the direct foreach attempt chain; deeper/nested catches or a continue skipping no work are reported.

  • Security detectors follow direct superglobals through coalescing, casts, suppression, match and assignment, including literal variable-variable/$GLOBALS reads and backtick commands. First-class callables no longer fail analysis; nested SQL reads are not duplicated.

  • Credential detection includes namespace/global constants, properties, static properties, array entries and ??= assignments, with fused/digit-suffixed names and punctuated secrets. Lowercase configuration keys, whole placeholders and parsed native type syntax remain exempt.

  • Duplication handles repeated blocks with lower memory use, balanced connected segments, touching same-file copies and CR/LF/CRLF locations; coverage reduction retains new copy relations. Each copy's bounded character-safe hint comes from its own source.

  • Layer matching recognizes leading backslashes, builtin case variants, interface extends and implicit builtin interfaces; templates apply graph-backed criteria/exclusions during expansion. Legitimate carve-outs and narrowed repeated patterns no longer produce false unreachable/shadow refusals; an empty exclude: {} excludes nothing instead of refusing.

  • Suppression audits distinguish stale selectors from unjudged hidden population; namespace suppression never removes a project finding or a file duplication/unused-directive finding merely by matching an empty namespace.

  • Inline directives bind around attributes and supported closure placements, read trailing comment sites, retain distinct same-line tags and reject malformed quoting. Promoted property-hook subjects preserve property names.

  • Baseline preflight catches missing/unreadable/malformed documents before analysis, preserves inert entries through rewrites and explains not-measured/undeclared-level/nonfinite states accurately. Explanation no longer scales quadratically with repeated findings.

  • Report/profile temporary contents stay owner-only before publication, log/lock modes follow umask, and private primary groups no longer cause false exposure refusals. Failed writes preserve the original final artifact.

  • Monotonic clocks prevent wall-clock adjustments from invalidating durations or lock deadlines. Missing runtime dependencies/extensions now fail installation by name instead of breaking packaged Git, parallel or Checkstyle execution.

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 a class: block on coupling.cbo left 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: ~ beside warning:/error: was refused with Cannot 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 the composer create-project/dist package; it is development-only and is now excluded via .gitattributes export-ignore, alongside the project's other dev-only trees.
  • Every health dimension now publishes what share of its subject it was computed over. --format=json gains a coverage object per dimension (measured count, eligible population, ratio, unit and the .count it came from) and --format=health prints 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.
  • bin/qmx rules now lists every option a rule accepts — at its own depth and inside each level slot — instead of only the ones that happen to carry a CLI alias. 54 of the 132 real options were reachable through --rule-opt and YAML but named nowhere; duplication.clone and code-smell.boolean-argument showed none at all. The three suppression keys are named once in a footer, and alias annotations now print their option in the same canonical spelling every refusal uses.
  • Configuration that binds to nothing is now reported instead of passing unnoticed, through six new channels, all warning at project level: discovery.unmatched-exclude (an --exclude value or exclude: entry that removed no directory), suppression.unmatched-path, suppression.unmatched-namespace and suppression.unmatched-rule-ledger (a suppress_paths / suppress_namespaces value, global or under rules.<name> — suppress_namespace_channels included — that names no analysed file and no declared namespace), coupling.unmatched-framework-namespace (a coupling.frameworkNamespaces prefix under which no name fell) and architecture.unmatched-exclude (a layer exclude: 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 everything composer.json declares as production code — psr-4 and psr-0 roots, classmap and files entries alike. A narrower run, or a project whose manifest declares no production autoload at all (no composer.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 for tests/ is not reported by qmx check src/ while a stale entry inside src/ 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 layer exclude: 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 by baseline:generate, so accepting one is a decision written by hand, and a shared configuration silences a single channel by name with disabled_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 omitted health.overall, even though health.overall can itself be overridden. The same refusal now fires identically whether the entry carried a formula or enabled: 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-warning and --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_methods and coupling.distance.include_namespaces each 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-opt examples that could not work: allowed_prefixes=is,has,can was never split on the commas — it set one prefix spelled is,has,can, which matches nothing. A CLI door carries one scalar; several values are written as a list in qmx.yaml. Migration: replace any --rule-opt for 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 directory 7331, and formula: "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 the docker run ... qmx check src/ that follows it had been broken since the first release: composer.lock was 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 answered Class "...ContainerFactory" not found on 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, metrics and sarif had drifted from the product on surfaces consumers parse with scripts: summary.infoCount, the health entries' worstContributors and the shape of their decomposition entries, the counters in the metrics summary, SARIF's tool.driver.informationUri, its rules[] catalogue, invocations[] and originalUriBaseIds, and the object a non-null acceptedLevel actually is ({shape, describe, count}, alongside the breach's promotion to error severity) 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.dir no longer offers --no-cache as 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 null key: ~ — 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, while coupling: ~, computed_metrics: ~, architecture: ~ and exclude_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. Inside rules: 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 --exclude already 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: or cache.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=7 no 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 malformed coupling: section — coupling: ~, coupling: {framework_namespaces: true} and the rest — used to answer with a bare coupling.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.example no longer ships three examples that fail: the rule selectors under disabled_rules: / only_rules: needed the X.* wildcard form, and the custom computed-metric example still used the retired ccn__avg variable encoding.
  • computed_metrics.<name>.levels written as a map (a value copied from the rules: section, which does have a per-level block) used to crash instead of refusing, and the three commands that read configuration crashed differently: check exited 1 with an "Unexpected error" and empty stdout, while directives and debug:layer-assignment exited 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:

  1. 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), then bin/qmx baseline:rename-channels qmx-baseline.json v0.26-rename-map.tsv.
  2. Configuration and command line — loud, exit 3. qmx.yaml (rules:, only_rules:, and the architecture: section's coverage: key), --rule-opt=, --disable-rule=, and any computed_metrics formula reading m["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.
  3. @qmx-ignore / @qmx-threshold in your own code — the suppression is lost. A directive naming an old channel becomes annotation.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.
  4. Anything that stored our output — silent. SARIF ruleId and rules[].name, GitLab check_name and fingerprint, Checkstyle source, 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.json now 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-progress is accepted by every command that shows progress, not by check alone: directives, debug:layer-assignment, baseline:generate, baseline:update, baseline:cleanup and baseline:explain take it too. graph:export analyses too but has never drawn a bar, so it does not take it.
  • bin/qmx directives reports what every inline @qmx-ignore and @qmx-threshold in the analysed tree actually does — effective, applied-boundary-only, inert, or unmeasured with a named reason — in text or json, under the same --preset, --only-rule, --disable-rule and --rule-opt the run being defended uses. It exits 2 on a proven inert directive and 4 when the run could not parse part of the tree.
  • bin/qmx directives takes --sweep=narrow|full (default narrow): a @qmx-threshold names one rule, so by default only that rule is re-executed to judge it; --sweep=full re-executes every enabled rule for the same verdicts. Both the text and --format=json report carry the sweep the verdicts were measured under.
  • The produced_findings count bin/qmx directives reports is the number of findings the rules produced. It no longer includes annotation.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 check now audits inline directives as part of check:self: a proven inert directive fails the aggregate the same way a red gate does.
  • bin/qmx rules prints, 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-threshold naming 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.ccn is eight edits from complexity.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 saved baseline:cleanup --remove=<selector> stops addressing a carried entry; re-read selectors from a fresh baseline:cleanup listing.

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-in health.* formula overridden in configuration exactly as it does to a user-defined computed.* one, and ignores health.*/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 neighbouring annotation.* 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 what qmx rules is 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 recognized self::X/static::X as 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-rule and --only-rule act on annotation.unused-directive. Naming it in --disable-rule was inert and said nothing, and an --only-rule naming a sibling channel of annotation.directive published it anyway. The channel is assembled after rule execution, which until now also meant assembled past the selection every other finding passes. The per-rule exclude_paths / exclude_namespaces under 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 -vv and -vvv a log line that arrived between two frames was wiped out and the bar itself froze at 0% 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_channels key 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, so code-smell.boolean-argument reached the run as codeSmell.booleanArgument and 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, the X.* group, the channel:namespace pair, 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 directives no 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-ignore in a docblock — now reports unmeasured and exit 0 when the rule is off at the level it sits on, as a rule disabled through a plain enabled: false already did, instead of inert and exit 2. The two physical forms, @qmx-ignore-file and @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 them inert.

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 → $diagnostics does not stop old code compiling, but the factory no longer calls getErrorOutput() on what it is given: a caller that passed a full ConsoleOutputInterface expecting 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), from Qualimetrix\Infrastructure\Console\ErrorStream — which is what RuntimeLoggerConfigurator does. 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, FindingFilterOrchestrator and GraphExportCommand no longer default it to an ErrorStream of 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) and GraphExportCommand($analyzer, $projection, $errorStream, $logger). Code composing these by hand should pass the one instance the container holds ($container->get(ErrorStream::class)), as bin/qmx does.
  • @qmx-ignore, @qmx-ignore-file and @qmx-ignore-next-line addressing duplication.code-duplication — the exact name, :project, or a group like duplication.* that reaches it — are now refused with annotation.unresolved-directive at 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-directive judged a suppression by the findings the report published rather than by the findings the rules produced, so a @qmx-ignore covering a finding that exclude_namespaces, exclude_namespace_channels or exclude_paths would 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-rank debt 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 other occurrence-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 metrics output format.
  • Added universal per-rule exclude_namespace_channels configuration for suppressing selected namespace-aggregate violation channels without hiding class findings or sibling channels.
  • architecture.coverage now includes analysed classes outside every declared layer even when they have no dependency edges, so coverage: error can enforce complete project ownership instead of checking only graph endpoints.
  • Rule and channel selectors now support an explicit X.* wildcard for "strictly the descendants of X", available everywhere a name is written: only_rules, disabled_rules, --only-rule, --disable-rule, per-rule exclude_namespace_channels, and the @qmx-ignore family.
  • New rule annotation.directive reports 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 of fail_on; annotation.unused-directive reports a directive that is valid but no longer fires, at a configurable unused_directive_severity (default info). Diagnostics name what can be addressed, so @qmx-threshold annotation.unused-directive is told that the string is a channel of the rule annotation.directive.
  • @qmx-ignore-file requires -- 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-ignore and @qmx-ignore-next-line is mandatory, so -- stays optional there.
  • Inline directives are validated after configuration resolves, so a @qmx-ignore naming a user-defined computed-metric channel such as health.cohesion resolves 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.something no 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 Y still counts as concrete, because implementing a declared contract is exactly the substitution point a plain list of literals lacks. New implementingEnumCount metric; enumCount keeps its meaning.
  • New rule architecture.unassigned-class counts analysed class-like declarations that landed outside every declared layer, without the vendor dependency-edge ends that drown architecture.coverage. Off by default; set mode: ignore|warn|error on it (CLI --unassigned-class-mode). It reports an absolute count, so a baseline can ratchet it down, and it reads the same single walk as architecture.layer-violation.
  • pending: true on a declared layer reserves it for code not written yet and exempts it from architecture.unreachable-layer. The new architecture.pending-layer-matched diagnostic 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=json for debug:layer-assignment returns the layer-assignment resolution (assigned layer, shadowing layers, criteria) as a machine contract, so an agent no longer parses the human report.
  • architecture.potential-shadow now 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 / extends criteria) 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:project now 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 for architecture.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, or format: suppressed in qmx.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-suppressed or by selecting the suppressed format itself, so the per-rule exclusion counts either surface reports never disagree; --show-suppressed on text prose covers only inline @qmx-ignore and per-rule exclusions, not the other mechanisms. A versioned snapshot of this repository's own composition lives under docs/internal/generated/suppression/ and is checked for freshness by composer check.

Breaking

  • A rule no longer declares the group it is listed under; bin/qmx rules reads the group off the first dot-separated segment of the producer's name. RuleInterface::getCategory(), the RuleCategory enum and the $category property of RuleMetadata and ProducerDeclaration are removed, replaced by a derived RuleMetadata::$family. Nothing you write or read changes: the same producers are listed under the same headings, --group takes 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 the getCategory() implementation from a rule class, and read RuleMetadata::$family (a string) where a RuleCategory was 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.health no longer names anything. A finding of health.complexity, health.cohesion, health.coupling, health.typing, health.maintainability, or health.overall now carries that dimension's own name in its rule field instead of computed.health; a user-defined computed metric carries computed. Every surface that used to address computed.health now addresses the dimension by its own name instead: qmx.yaml rules: section keys (rules: { health.cohesion: { ... } }, not rules: { computed.health: { ... } }), --disable-rule, --only-rule, only_rules/disabled_rules, exclude_namespace_channels keys, and @qmx-threshold/@qmx-ignore directives. bin/qmx rules lists 51 rules instead of 45, under two group headings that did not exist before — Health and Computed — in place of the Maintainability heading computed.health used to be listed under. The debt breakdown (text/verbose output, JSON violationsMeta.byRule, the HTML report's ruleName field) 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, while computed_metrics: { health.cohesion: { enabled: false } } removes the dimension itself and renormalizes health.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 / .class become complexity.cyclomatic, the same for complexity.cognitive, complexity.npath, and coupling.cbo.class / .namespace and coupling.instability.class / .namespace become coupling.cbo and coupling.instability. The level was written twice — once in the name and once in the subject every finding already carries — and only these five rules ever wrote it into a name; the six health.* channels have always reported at three levels under one name. Where a level mattered, address it beside the name with channel:level, level being one of callable, class, file, namespace, project: @qmx-ignore coupling.cbo:namespace silences 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 in exclude_namespace_channels keys. Three things to change by hand. Rename every occurrence of the ten old names — selectors, directives, exclude_namespace_channels keys, baseline:explain --channel — dropping the level segment and, where the distinction was wanted, adding :level instead; a .class suffix 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 the getFingerprint()-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 rules is 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-threshold refuses a channel:level pair 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-ignore forms, 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#violationCode pair 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 (rule in --format=json) and an edge of the registry. Everywhere the pair used to be written, write the channel name alone — @qmx-ignore complexity.cyclomatic instead of @qmx-ignore complexity.cyclomatic#complexity.cyclomatic.callable, the same for @qmx-ignore-next-line, @qmx-ignore-file and @qmx-threshold, for --only-rule / --disable-rule / only_rules / disabled_rules, for per-rule exclude_namespace_channels keys, for baseline:explain --channel, and for the channel field 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 whose channel still carries a # go inert as malformed, so regenerate with bin/qmx baseline:generate <baseline> <paths...> --force and review the result. And the getFingerprint()-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\Violation is now Qualimetrix\Analysis\Finding\Contract\Finding, ViolationChannel is FindingChannel, and the property, parameter and named argument violationCode is code. Every derived type follows: ViolationFilterInterface, ViolationFilterStage, ViolationFilterStageInterface and ViolationFilterStageResult become FindingFilter*; Reporting\Filter\ViolationFilter becomes FindingFilter; MeasuredViolationSet becomes MeasuredFindingSet; ViolationFilterOrchestrator becomes FindingFilterOrchestrator; ViolationSorter, ViolationDetailRenderer, DetailedViolationRenderer, ViolationSummaryRenderer, JsonViolationSection and HtmlViolationPartitioner become FindingSorter, FindingDetailRenderer, DetailedFindingRenderer, FindingSummaryRenderer, JsonFindingSection and HtmlFindingPartitioner. 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 already code, the HTML report's embedded payload still spells its three aliases ruleName / 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::$level is removed rather than renamed. Five hierarchical rules wrote it and nothing read it except the object's own copy in reportedAsBreach(); a finding's level is carried by its subject, 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 a level: argument.
  • design.type-coverage is three rules: design.param-type-coverage, design.return-type-coverage and design.property-type-coverage, one channel each, each with its own threshold, suppression and baseline entry. Migration is mechanical. Configuration: one design.type-coverage section with param_warning/param_error/param_threshold and the return_*/property_* equivalents becomes three sections keyed by the new names, each taking bare warning/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-coverage matches nothing — name the three, or design.*. @qmx-threshold design.type-coverage W E no 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 for design.type-coverage#design.type-coverage.param and its siblings must be renamed to design.param-type-coverage#design.param-type-coverage and siblings, or regenerated. bin/qmx rules reports 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-class gate moved to its own rule. rules: { architecture.layer-violation: { unassigned_class: warn } } becomes rules: { architecture.unassigned-class: { mode: warn } }, and --layer-violation-unassigned-class becomes --unassigned-class-mode. The rule has no enabled key: mode: ignore is how it is declined. Neither --disable-rule=architecture.layer-violation nor rules: { 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 a did you mean tie-break; no name is close enough to this one to tie, and that is measured rather than assumed. See ADR 0030.
  • SymbolLevel and SymbolLevelProjection moved from Qualimetrix\Analysis\Evidence\Measurement\Contract to Qualimetrix\Core\Symbol, next to SymbolType, SymbolPath and MetricSubject. The level is a coordinate of a symbol — it is declared by rules, read off the symbol by Finding::level(), written right of the colon in channel: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 RuleLevel is gone; SymbolLevel is now the project's one level vocabulary, and HierarchicalRuleInterface and HierarchicalRuleOptionsInterface name 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.lcom to cohesion.lcom and its qmx rules --group category from Design to a new Cohesion group; the rule's algorithm, defaults, options, and CLI aliases are unchanged. Update every surface that names the old channel by string: qmx.yaml rule keys, --only-rule / --disable-rule selectors (design.* no longer sweeps LCOM in; use cohesion.* or cohesion.lcom), @qmx-ignore / @qmx-threshold directives, and baseline entries. A baseline entry still naming design.lcom does not fail the run — it degrades through InertEntryReason::UndeclaredChannel, the same fail-safe path any channel a baseline entry no longer resolves to already takes. See ADR 0060.
  • design.data-class was inverted on its reported axis and could not report a Data Class at all. woc measured 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, and minMethods counted against non-accessor methods) removed every real data class — including the rule page's own "Flagged" example. woc is 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 on woc <= woc_threshold, whose default changes from 80 to 33, and its channel direction is now Lower. 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_methods becomes min_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 configured woc_threshold (the old value is not merely stricter, it means the opposite), rename min_methods to min_members, and regenerate any existing baseline with bin/qmx baseline:generate <baseline> <paths...> --force — its design.data-class magnitudes were stored under the old channel direction and would be read as breaches. The woc value in --format=metrics/json output 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, where n is 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 with bin/qmx baseline:generate <baseline> <paths...> --force and reviewed. The same change resets the getFingerprint()-derived identifiers GitLab Code Quality and SARIF use to track findings across runs, and irreversibly resets the occurrence key of architecture.layer-violation baseline entries, whose evidence contains the declaration key (no other channel's does — architecture.circular-dependency keys 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_channels key 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 — so coupling.*:namespace under coupling.class-rank is refused, where before the level was witnessed by coupling.cbo and the production by coupling.class-rank. And namespace is the only level such a key may name, because the option is offered namespace-aggregate findings and nothing else: coupling.cbo:class is refused by name, with the spelling that works. Level-free keys are unchanged, and channel:namespace excludes exactly what the bare channel does.
  • Baseline files are now version 12. A magnitude-shaped entry no longer stores count alongside magnitudes — 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 the getFingerprint()-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 with bin/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.lcom no longer counts __construct/__destruct as 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 the callable level 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-code now reports at Error severity (as documented) and detects debug_zval_dump().
  • exclude_namespaces (global --exclude-namespace and 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 carried null.
  • 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-threshold overrides to Value Object constructor limits and recognize the top-level CBO scope option without a false unknown-option warning.

Breaking

  • fail_on no longer accepts info. Allowed values are none, warning and error. Severity info is now report-only: an Info-only run always exits 0, which makes severity: info a declaration of "observe, do not gate" instead of the old trick of configuring an unreachable threshold. To gate on a diagnostic shipped at info, 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. --namespace filtering, health drill-down, worst-offender listings and the coupling.distance include_namespaces option 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.coverage no longer selects a hypothetical architecture.coverage.source. If you relied on a selector reaching a descendant channel, name the descendant or use the explicit X.* form.

  • @qmx-threshold no longer accepts a prefix or *. @qmx-threshold coupling 15 and @qmx-threshold * 15 are 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-file are unaffected: they mean "no rule filter here", not "every rule name", and continue to work.

  • @qmx-ignore requires 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.directive names 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, duplication or code-smell used to stand for the whole family and now selects nothing, which is reported rather than guessed at. Four surfaces are affected: disabled_rules / only_rules in both spellings (the shipped legacy preset used disabledRules), the CLI --disable-rule / --only-rule, the per-rule exclude_namespace_channels map keys, and the three inline suppression forms. Migration is mechanical: disabled_rules: [complexity] becomes [complexity.*]. Note that X.* means strictly the descendants of X — 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-directive finding, which is a configuration error and therefore gates regardless of fail_on — including under fail_on: none — and cannot be accepted by a baseline or silenced by another @qmx-ignore.

  • @qmx-threshold on 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-ignore naming 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_severity and empty_template_severity no longer exist in either spelling, and the CLI flags --layer-violation-unreachable-layer-severity, --layer-violation-potential-shadow-severity and --layer-violation-empty-template-severity are gone with them. The layer-policy diagnostics — architecture.coverage, architecture.unreachable-layer, architecture.potential-shadow, architecture.empty-template and architecture.pending-layer-matched — now report a mistake in the configuration rather than debt in the code: they fail the run unconditionally without consulting fail_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, set coverage: ignore in the architecture section. architecture.layer-violation is unaffected — it is real code debt, and @qmx-ignore architecture.layer-violation and baseline entries still apply to it.

  • The mixed configuration runtime surface was removed without compatibility aliases: TransitionalResolvedConfiguration, TransitionalRuntimeConfiguration, its provider/holder, and ConfigurationContext no longer exist. Invoke ConfigurationPipelineInterface::resolve(ConfigurationResolutionRequest) and pass the concrete ConfigurationDocument only to a named owner resolver. Use RunConfigurationResolverInterface for invocation data, FindingConfigurationResolverInterface for 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 now Analysis\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-owned Span were removed. Collection code imports Analysis\Run\Contract\Progress\ProgressReporterInterface; instrumentation imports Core\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, and aggregation.auto_depth were removed and are now rejected as unknown. Project namespace discovery uses the invocation working directory's composer.json, and aggregation follows analyzed declarations.

  • Private Symfony container references are now recorded as permanent exact composition_binding entries 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\FrameworkNamespaces and Qualimetrix\Core\Coupling\FrameworkNamespacesHolder were removed with no compatibility shim. Coupling now owns its run-scoped framework-namespace state. Composition consumers must inject Qualimetrix\Analysis\Evidence\Coupling\Contract\Configuration\CouplingConfiguratorInterface and call configure(ConfigurationDocument $document) for every run, including an empty document to reset prior state. Configuration producers keep using the canonical coupling.framework_namespaces document 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\*, and Qualimetrix\Core\Violation\* imports with their Qualimetrix\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 from Core\Suppression / Baseline internals to Analysis\Policy\Inline; baseline lifecycle and accepted-boundary types now live under Analysis\Policy\Baseline. Impact ranking and technical-debt calculation moved to Analysis\Evidence\Prioritization. Replace direct Console ViolationFilterPipeline and Infrastructure GitScopeFilter composition with Reporting\FindingProjection\FindingProjector and its GitScopeQueryInterface; the shipped adapter is Infrastructure\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\ComputedMetrics capability. Replace Qualimetrix\Configuration\ComputedMetricsConfigResolver and Qualimetrix\Configuration\ComputedMetricFormulaValidator with the same-named classes at the new capability root. Replace Qualimetrix\Configuration\ComputedMetrics\Contract\HealthFormulaExclusionInterface with Contract\Configuration\HealthFormulaExclusionInterface and Qualimetrix\Configuration\HealthFormulaExcluder with Health\Configuration\HealthFormulaExcluder under that root. Move remaining Core\ComputedMetric\*, Metrics\ComputedMetric\*, Rules\ComputedMetric\*, and Reporting\Health score, offender, metadata, ranking, and drill-down imports to their corresponding root, Contract\*, and Health\* declarations under the new capability; Reporting retains only thin report assembly and projection consumers. The evaluator API changes from compute($repository, $definitions) to evaluate($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 either Qualimetrix\Analysis\Policy\Architecture\* for declared-layer policy or Qualimetrix\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\*, and Analysis\Lifecycle\* with their Qualimetrix\Analysis\Run\Contract\*, Analysis\Run\Collection\*, Analysis\Run\Discovery\*, and Analysis\Run\Pipeline\* counterparts. In particular, use Run\Contract\Pipeline\AnalysisPipelineInterface for adapters and Run\Contract\FileSetInspectionParticipantInterface for 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 with Qualimetrix\Analysis\Evidence\Measurement\*. External consumers must use the corresponding Measurement\Contract\* type; repository indexes, visitors, and aggregation implementations are internal.
  • Configuration moved without aliases from Qualimetrix\Configuration\* to Qualimetrix\Analysis\Configuration\* where P3 moved the type. The final surface is ConfigurationPipelineInterface::resolve() returning concrete ConfigurationDocument; 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 declared Analysis\Evidence\DependencyModel\Contract\DependencyTraversalParticipantInterface where an external promise is needed. Extraction internals have no public replacement. Tests move with their subject and must be discovered from their new tests/Analysis/... paths.
  • Dependency graph types moved without aliases: replace Qualimetrix\Core\Dependency\Dependency, DependencyType, and DependencyGraphInterface with their Qualimetrix\Analysis\Evidence\DependencyModel\Contract\* equivalents; replace EmptyDependencyGraph with the internal capability implementation only inside composition. Replace the concrete Analysis\Collection\Dependency\DependencyGraphBuilder dependency with Contract\DependencyGraphBuilderInterface; graph implementations are no longer public module dependencies.
  • Graph export is now a Reporting projection contract. Replace Analysis\Collection\Dependency\Export\GraphExporterInterface and direct DotExporter/JsonGraphExporter construction with Qualimetrix\Reporting\GraphProjection\Contract\DependencyGraphProjectionInterface::project() plus GraphProjectionRequest. The old exporter interface was removed, implementations moved under Reporting\GraphProjection, and no compatibility aliases or shims are provided.
  • Duplication implementation moved without aliases or shims: update Qualimetrix\Analysis\Duplication\*, Qualimetrix\Core\Duplication\*, and Qualimetrix\Rules\Duplication\* imports to Qualimetrix\Analysis\Evidence\Duplication\*. The intermediate DuplicationInspectionInterface is removed; final composition registers the internal DuplicationDetector as an implementation of Qualimetrix\Analysis\Run\Contract\FileSetInspectionParticipantInterface. Remove duplicateBlocks arguments/reads from AnalysisContext and EnrichmentResult; no Duplication-owned public inspection contract remains. The capability-owned rule reads the internal DuplicationResultProvider.
  • architecture.allow now rejects every directed cycle made only of exact selectors with ConfigLoadException. 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 Callable instead of Method, including symbol/rule levels and *.callable channels. Update enum cases, configuration selectors, stored channel names, and integrations; there are no Method aliases.
  • 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:migrate command 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 symbol alone must use channel + subject + optional occurrence + optional edge.
  • A Violation carrying dependencyTarget without dependencyType now emits JSON edge: {"target": "..."} instead of edge: 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.
  • CollectionOrchestrator no longer creates default null progress/logger collaborators. Pass mandatory Run progress and PSR logger instances; shipped DI injects the instance-owned Console progress switch and DelegatingLogger, while every direct constructor call must provide its chosen implementations. No nullable/default overload or compatibility shim is provided.
  • FileParserInterface now requires parseContent(SplFileInfo $file, string $content): array. Implementations must parse the supplied bytes while using $file as diagnostic source identity.
  • DependencyGraphAnalyzerInterface, DependencyGraphAnalyzer, and DependencyGraphAnalysisResult moved from Qualimetrix\Analysis\Collection\Dependency to Qualimetrix\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 with baseline:update or explicitly selected baseline:cleanup --remove entries.
  • 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 coverage object, 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() and isViolationCodeEnabled() were removed because configuration no longer owns selection semantics. Inject RuleSelector and pass the producer name plus ViolationChannel; this preserves channels whose producer name, channel ruleName, and violationCode differ.
  • 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. Use bin/qmx baseline:generate <file> <paths...> instead.
  • --baseline-ignore-stale was removed. Stale entries now report without failing a run or disabling other baseline entries; inspect and remove only explicitly selected entries with bin/qmx baseline:cleanup <baseline> <paths...> --remove=<selector>.
  • --no-suppression was renamed to --no-suppression-annotations with 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 an architecture.circular-dependency recommendation now lists fully qualified class names in its cycle array, 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-dependency must 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 run bin/qmx baseline:migrate <baseline> <paths...>; for a v10 file, review the capture and replace it with bin/qmx baseline:generate <baseline> <paths...> --force. Entries for other rules are unaffected.
  • @qmx-threshold accepts only a non-negative numeric shorthand or the generic warning=N / error=N keys (one or both, in either order). Arbitrary YAML / --rule-opt option 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, and graph:export return 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-owned methodLoc / halstead.methodLoc metric was removed. Rename minLoc to minStatements, YAML and --rule-opt key min_loc to min_statements, and CLI alias --mi-min-loc to --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 match arm, nullsafe access, and expression-bearing for, foreach, switch, and echo slots. Existing NPath values, thresholds, and baselines may shift.

Fixed

  • --only-rule now selects the full finding channel instead of assuming a rule name prefixes its violationCode. --only-rule=computed.health, --only-rule=health.complexity, and --only-rule=computed.health#health.complexity now all run the computed.health producer and retain the intended findings; Architecture diagnostic channels such as architecture.coverage and 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-dependency now 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-dependency no 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 between App\Billing\Service and App\Orders\Service reads Billing\Service → Orders\Service → Billing\Service instead of the useless Service → 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-threshold parsing now validates the entire value expression instead of accepting warning= or error= 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-rule selectors 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.
  • check diagnostics are routed to stderr, keeping stdout valid for the selected report format even on configuration/input errors, deprecations, logging, and output-file notices.
  • baseline:explain now 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-argument no 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. Set flag_promoted_properties: true to restore the previous behavior.
  • duplication.code-duplication no longer flags a duplicate block that lies entirely inside a const array 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-opt were 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 bogus Unknown option warning. All three channels now agree, and --preset=strict applies its vo-error value instead of dropping it. Values were dropped for code-smell.long-parameter-list and design.type-coverage; coupling.distance only ever suffered the false warning.
  • The documented threshold: shorthand crashed the whole run with Cannot mix "threshold" with "warning"/"error" (exit code 3) whenever it was written at the top level of a rule, which is exactly how website/docs/getting-started/configuration.md shows it. 15 rules were affected; the nested method: {threshold: …} form was never broken.
  • The threshold, vo-threshold and *_threshold shorthands no longer produce a false Unknown option warning on rules that support them.
  • coupling.cbo and coupling.instability now accept the threshold shorthand 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 bare threshold with a bewildering Unknown option warning.
  • code-smell.long-parameter-list never applied its vo-warning / vo-error thresholds: value-object constructors were reported against the ordinary thresholds instead. The VO detection flag never reached the rule.
  • architecture.unreachable-layer no 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 where architecture.layer-violation flagged 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 and graph:export.
  • Global exclude_namespaces (and --exclude-namespace) no longer suppress architecture.* 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-shadow and architecture.empty-template are configurable via unreachable_layer_severity, potential_shadow_severity and empty_template_severity on the architecture.layer-violation rule. Defaults are unchanged, so a typo in patterns: can now fail the build instead of only whispering at info level.
  • Violations dropped by per-rule exclude_namespaces / exclude_paths are now reported: -v prints how many were suppressed and by which rules, and --show-suppressed lists 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.coverage with coverage: warn now reports Warning severity instead of Info, matching the mode's name. If you relied on it staying silent under fail_on: warning, switch to coverage: ignore or raise fail_on to error.
  • 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-dependency may 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 the legacyWarningKeys / legacyErrorKeys parameters with a single legacyKeys array keyed by warning / error / threshold. Named-argument calls fail with Unknown named parameter; the old positional form silently loses its legacy keys. Only affects third-party rule packages calling the parser directly.
  • RuleExecutorInterface gained getRuleExclusionStats(). Third-party implementations of the interface must add it.
  • ViolationFilterOrchestrator::__construct() takes an additional required RuleExecutorInterface argument. Only affects code constructing it directly; the container wires it automatically.

0.21.0 - 2026-07-28

Fixed

  • qmx rules crashed with a fatal ArgumentCountError instead 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 check was 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 (tag qmx.rule); getClasses() and getAllCliAliases() still cover metadata.
  • RulesCommand::__construct() now takes iterable<RuleInterface> $rules instead of a RuleRegistryInterface. 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 --version printed things like 1.0.0+no-version-set, and the same wrong value was stamped into every analysis artifact — version in JSON and SARIF, toolVersion in 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/yaml updated 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/cache updated to v8.0.14, clearing CVE-2026-45073. It is pulled in transitively by symfony/expression-language, which backs computed metric formulas.

Breaking

  • AnalysisConfiguration::{projectRoot, cacheDir, composerJsonPath} are now typed as AbsolutePath / ?AbsolutePath instead of string / ?string. Embedding consumers that construct AnalysisConfiguration directly must wrap path arguments in AbsolutePath::fromString(...). The no-arg constructor still works as before — defaults resolve lazily to getcwd() and ${projectRoot}/.qmx-cache. fromArray() and merge() continue to accept string values from YAML / CLI input and resolve them via PathFactory::fromCliArgument(). ADR 0015 Phase 5.
  • BaselineWriter::write() now requires AbsolutePath for the $projectRoot parameter (was optional string = '.'). Embedded callers must wrap their project root and pass it explicitly.
  • GitClient::getProjectRoot() accessor removed. The project root is now owned by GitScopeResolution (returned from GitScopeResolver::resolve()); pass it explicitly to consumers that previously read it from GitClient.
  • FileProcessorInterface gains setProjectRoot(AbsolutePath): void. CollectionOrchestratorInterface::collect() gains a required AbsolutePath $projectRoot parameter. 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 / RelativePath VOs 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 AbsolutePath and RelativePath VOs instead of string throughout GitClient, GitRepositoryLocator, and GitScopeFilter. ADR 0015 Phase 1b.
  • GitScopeFilter now performs eager git-to-project path translation at the GitClient boundary. 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.yaml now declares the full 27-layer architecture topology (Core + Configuration + Architecture slice + per-category metrics-{Category} template + 10 analysis-* sub-layers + 10 infra-* sub-layers) that previously lived in deptrac.yaml. Sub-layer enforcement (e.g. analysis-discovery → analysis-pipeline is now caught) gained, on top of features deptrac never had: per-category metric isolation via template expansion, and a relations: filter that permits infra-di → metrics-* references but forbids inheritance. ADR 0014.
  • Violation::$location->$file is now typed as ?RelativePath (was string). Architecture violations not tied to a single file use Location::none() (file is null). Wire/comparator surface preserved via Location::pathString() and Location::isNone() — formatters and JSON output emit the same shape as before, but file paths going into Location must be project-relative. WorstOffender::$file and DuplicateLocation::$file migrated similarly; ParseException::$filePath now carries AbsolutePath (lives at the parser boundary, where absolute paths are the natural representation). ADR 0015 Phase 1a.

Removed

  • deptrac/deptrac dev-dependency. composer check is now cs-check + test + phpstan + selfcheck; architecture enforcement runs entirely through Qualimetrix's own architecture.layer-violation rule.
  • Internal Qualimetrix\Core\Util\PathNormalizer helper (was @internal since v0.18). Superseded by Core\Path\PathFactory. ADR 0015 Phase 6 also wires a PHPStan rule (qmx.bannedStringPathProperty) as a regression guard against re-introducing string-typed $file / $filePath / $oldPath properties in scoped namespaces.

Fixed

  • The HTML report build manifest (src/Reporting/Template/package.json and its lockfile) is now tracked. A blanket *.json ignore rule had been excluding it, so a fresh clone could not run composer test:js or composer build:js, and the committed dist/ bundle could not be regenerated or audited.

0.19.0 - 2026-05-17

Breaking

  • ThresholdAwareOptionsInterface gains a static getOverrideValidator() accessor that returns the per-rule OverrideValidatorInterface strategy used to validate @qmx-threshold annotations. Custom Options classes in extension code must implement the new method or use StandardOverrideValidatorTrait; for the default warning ≤ error + non-negative semantics. See ADR 0013.

Changed

  • Invalid @qmx-threshold annotations now surface a rule-specific code (e.g. warning_exceeds_error, error_exceeds_warning, error_not_supported) as violationCode: annotation.invalid-threshold.<code> in JSON / SARIF / Checkstyle output; the human message is unchanged. Validators that provide a remediation hint (e.g. WarningOnly's "omit error=...") now flow through to recommendation so users see actionable follow-up.

Fixed

  • @qmx-threshold maintainability.index warning=N error=M annotations with N > M were silently rejected by the parser, even though the rule's defaults are warning=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=M with N > M was 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=M was rejected when N > M, but the rule maps warning to wocThreshold (high) and error to wmcThreshold (low) — independent metrics on independent axes. The annotation now validates accordingly.
  • @qmx-threshold design.god-class warning=W error=E previously accepted the error value silently and then discarded it inside withOverride(). Explicit error=N is now rejected at parse time with a clear diagnostic; the shorthand form @qmx-threshold design.god-class N still works.

0.18.0 - 2026-05-16

Breaking

  • architecture.layers YAML 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: replace layers: { name: pattern } with layers: [{ 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 old Qualimetrix\Core\Architecture, Qualimetrix\Configuration\Architecture, Qualimetrix\Analysis\Architecture, or Qualimetrix\Rules\Architecture namespaces must update imports.

Changed

  • New rule architecture.layer-violation: declare layers in YAML and enforce allowed inter-layer dependencies. Membership supports patterns, suffix, attributes, implements, extends (combined via match: any | all); parameterised template layers expand against the observed class set ({var} capture); exclude: blocks hard-filter assignment; allow-list relations: whitelists restrict permitted DependencyType kinds; capture-binding ('app-{m}': ['domain-{m}']) constrains allows to same-instance edges for DDD bounded contexts. Incremental adoption via architecture.coverage; expansion capped by architecture.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 matches qmx check byte-for-byte.
  • qmx.yaml.example includes a commented-out architecture: stanza demonstrating multi-criterion membership, exclude:, templates, vendor layers, allow: (plain, captured same-instance, long-form with relations:), coverage, and max_expanded_layers.

Fixed

  • @qmx-threshold annotations on design.type-coverage, design.god-class, and design.data-class previously had no effect — the Options classes did not implement ThresholdAwareOptionsInterface. The three Options now implement it and apply overrides per class.
  • architecture.layer-violation now respects @qmx-ignore suppressions placed on the offending class — the dependency visitor used absolute paths while the suppression map was keyed by relative paths.
  • architecture.layer-violation no longer false-positives mutual-allow when the two directions use disjoint relations: filters or allow_cross_instance: true.
  • architecture.max_expanded_layers now actually takes effect when set in YAML (previously silently camelCased and ignored). See ADR 0009.
  • architecture.allow source and target selectors now reject [ brackets at config-load time with an actionable hint suggesting {var} capture-variable syntax.
  • debug:layer-assignment now honours memory_limit from qmx.yaml.
  • Architecture configuration warnings (e.g. mutual-allow detection) now actually reach the user logger.
  • SARIF formatter $schema URL updated to the OASIS canonical location after the upstream repo reorganized.

0.17.0 - 2026-05-12

Fixed

  • health.typing no 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: false no longer breaks health.overall. Both enabled: false and exclude_health: [X] now follow the same pipeline — the dimension is removed and health.overall weights are renormalized across the remaining dimensions.

Changed

  • Excluding a health dimension when health.overall has 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.coupling namespace 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 ce metric 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 -v flag due to NullLogger at default verbosity

0.14.0 - 2026-04-03

Changed

  • --exclude-namespace CLI option for violation suppression by namespace (prefix or glob), merged with exclude_namespaces from qmx.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-suppressed now lists each suppressed violation with file, line, message, and rule name (was count-only)
  • exclude_paths and exclude_namespaces now support both prefix matching (src/Entity) and glob patterns (src/Metrics/*Visitor.php); simple directory/namespace names work without trailing /*
  • --exclude-health with 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_methods option to exclude specific methods from the cohesion graph (reduces false positives from interface-mandated methods like getName, getDescription)
  • Partial scope warning when analysis paths don't cover all composer.json autoload entries
  • coupling.instability: min_afferent option replaces skip_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 via allowed_prefixes: [])
  • code-smell.error-suppression: allowed_functions option to whitelist functions where @ usage is acceptable (e.g., fopen, unlink)
  • Per-rule exclude_paths option for targeted violation suppression by file path patterns
  • @qmx-ignore tags 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=50 to restore the old behavior
  • Global exclude_namespaces config option for suppressing violations by namespace prefix (like exclude_paths but 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:export command crash due to -d shortcut conflict with global --working-dir

Removed

  • --analyze option — was misleading (analyzed all files regardless, only filtered violations like --report). Use --report instead
  • analyze command alias — use check instead
  • baseline.json — replaced with proper qmx.yaml configuration using new features

0.11.2 - 2026-04-02

Changed

  • Project qmx.yaml for self-analysis with tuned coupling thresholds and exclude_namespaces for Core value objects
  • qmx.yaml.example — comprehensive annotated example with documentation links, default values, and all available options (replaces qmx.yaml.dist)
  • parallel section in config file for setting worker count (was CLI-only via --workers)

Fixed

  • coupling section in config file was rejected as unknown key

0.11.1 - 2026-04-01

Changed

  • --memory-limit option and memory_limit config key to control PHP memory limit (e.g., --memory-limit=1G)
  • Removed hidden 512M memory limit override — PHP's memory_limit from 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-class and code-smell.data-class renamed to design.god-class and design.data-class
  • --format=health now produces a text table (was HTML). Use --format=html for the interactive HTML report

Changed

  • @qmx-threshold annotations for per-class/method threshold overrides in source code
  • Framework CBO distinction: cbo_app and ce_framework metrics separate application from framework coupling
  • Full dependency graph in --analyze=git:* modes — coupling metrics now correct in partial analysis
  • --group-by=class|namespace for 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-threshold targets 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/.p95 now 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 GitScopeFilter for --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 GitFileDiscovery class

0.9.2 - 2026-03-26

Fixed

  • CI: refactored ConfigDataNormalizer to 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 recommendation when available. Handles architectural violations ([project])
  • HTML report: violations table now shows File column, uses violationCode (more specific than ruleName), and prefers recommendation over technical message

0.9.0 - 2026-03-26

Changed

  • Analysis presets: --preset=strict|legacy|ci for one-flag configuration. Multiple presets can be combined (--preset=strict,ci). Custom preset files supported via path (--preset=./team.yaml)
  • rules key now uses deep merge across pipeline stages — partial rule overrides in qmx.yaml no 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=N option (default 10, --top=0 to 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-on now defaults to error — warnings are shown in output but don't cause non-zero exit code. Use --fail-on=warning or fail_on: warning in config for the old behavior
  • threshold shorthand 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 (was Strong / 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-on changed from warning to error. CI pipelines relying on exit code 1 for warnings must add --fail-on=warning explicitly

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_namespaces is now a universal per-rule option available for any rule, not just coupling rules

Breaking

  • exclude_namespaces for coupling.cbo and coupling.instability moves from nested namespace: to top-level rule config
  • exclude_namespaces now 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=DIMENSION to exclude dimensions from scoring
  • Computed metrics: 6 built-in health.* scores plus user-definable computed.* metrics via Symfony Expression Language formulas, per-level formulas, threshold-based violations
  • Summary-first CLI: --format=summary is now the default output — health bars, worst offenders, violation summary, and contextual hints in one screen
  • Drill-down navigation: --namespace=App\Service and --class=App\Service\UserService for 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 / -o to write any format to a file
  • JSON output redesigned: summary-oriented with meta, summary, health decomposition, 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, --detail shows per-rule breakdown
  • --fail-on=error option to allow warnings without failing the build
  • --include-generated to override automatic @generated file skipping
  • --disable-rule=duplication now 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 list
  • composer benchmark:check regression suite — validates health scores against 15 open-source projects
  • llms.txt and llms-full.txt — machine-readable documentation for AI coding agents

Fixed

  • Metric algorithm corrections: cognitive complexity nesting in closures, cyclomatic complexity for match arms, 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-line scoped 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: --config now functional, exclude_paths accepted, 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=html renamed to --format=health; --format=metrics-json renamed to --format=metrics
  • --format=summary is now the default (was text). Use --format=text for the previous behavior
  • --format=json redesigned — no longer PHPMD-compatible. See documentation for new schema
  • JSON field humanMessage renamed to recommendation in 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 analyze to check, with aliases for backward compatibility
  • Canonical config file name is now qmx.yaml
  • exclude_paths option 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=0 semantics corrected

0.2.2 - 2026-03-05

Changed

  • Rule NAME constants follow group.rule-name format (kebab-case)
  • SizeRule split into MethodCountRule and ClassCountRule
  • CouplingRule split into InstabilityRule and CboRule
  • RuleMatcher utility for prefix-based rule matching
  • ANSI colors, grouping, and FormatterContext for 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

  • violationCode field in Violation for stable baseline hashing
  • Improved violation messages with thresholds and actionable advice

Fixed

  • Namespace-level violation display and minClassCount filter

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-ignore suppression tags
  • AST caching, progress bar, PSR-3 logging
  • Git hook installation (hook:install, hook:status)
  • Symfony DI with autowiring and autoconfiguration
  • GitHub Actions workflow