Skip to content

Suppression Rules

Suppression rules report on the run's own suppression configuration rather than on the code it analysed. A suppress_paths or suppress_namespaces value that names nothing hides nothing and never will, while its author believes it is hiding something.


Suppression Configuration

Rule ID: suppression.configuration

Nothing is reported under the producer name suppression.configuration itself — it exists so the channels below have one owner to disable and configure as a family. Each channel has its own name and its own meaning.

What it measures

Every suppression value the run was given — global or under rules.<name> — is checked against what the run actually saw: suppress_paths against the files it analysed, suppress_namespaces against the namespaces it declared. A value matching neither is reported.

A project-level finding has no namespace, so no suppress_namespaces value — global or under rules.<name> — removes one, however broad its pattern. (project), which a report shows where such a finding's namespace would be, is a display value and not a namespace: written as suppress_namespaces: [{exact: '(project)'}], at either level, it removes nothing and is reported here like any other value that names nothing. To silence a project-level finding, disable its channel or accept it in a baseline.

Why it matters

This is a different zero from the one --format=suppressed already reports. That report's neverMatched list is built from removals, so it answers "this suppressor removed nothing" — a state an honest, paid-down suppression also reaches, and the right response to which is to celebrate and delete. The channels here answer "this suppressor named nothing", which no amount of repaired code can cause. Only a typo, a rename, or a move can.

The two questions look alike in a report and lead to opposite actions, which is why they are answered separately.

Scope and severity

All the channels report at project level, at severity warning.

Measured project scope asks two questions. Namespace absence needs complete declaration evidence: omitted PHP, authored PHP removal, generated exclusions and an uncertain universe withhold it. Path values use PHP-path completeness and universe certainty separately; authored/generated removal alone does not close that question. Rule-ledger namespace values also ask the declaration question. For a literal (exact or subtree) namespace value that question is narrower, see Excluded and generated code.

Every value is placed separately. Accepted production/development PSR-4 facts from the invocation snapshot locate namespaces independently of report state; without a map they remain unjudged. A literal under a removed entry is not judged, and removed code that may declare its namespace withholds it. Path/ledger regex is unjudged with incomplete paths or authored removal that could hide a match; namespace regex also requires complete declaration evidence. This conservative answer can withhold an unrelated stale regex: rerun without the exclusion. A path value that binds no analysed file is not judged either when an entry the run discovered but did not analyse may hold what it names: a failed file or a file excluded as @generated that the value matches, or a directory the run could not list or did not follow that the value's literal lies beneath (a regex value, beneath any such directory). Reports name every skipped {channel, option, pattern} value, including on a covered run.

Excluded and generated code

Excluded PHP files and @generated files are not analysed, but the run reads the namespace statements of every one of them (a token scan, or for files over 64 KiB a text scan that may withhold more; no analysis) and judges a literal exact or subtree suppress_namespaces value, global or under rules.<name>, unless that code could have declared a namespace the value binds. A stale exact: App\Does\Not\Exist is therefore reported although src/Kernel.php is excluded; exact: App is not judged while the excluded file declares App. A declared namespace also makes its ancestors undecidable: a file in App\Foo\Bar withholds exact: App\Foo. Deciding whether removed code could declare a namespace ignores ASCII case; the value itself is still matched case-sensitively, as configured.

A path value (suppress_paths) naming a file excluded as @generated is not judged either: the file was found and intentionally not analysed, so the spelling is not in doubt (see Scope and severity).

What is still withheld:

  • Every value whose removed code is unknown: an unreadable excluded file, an excluded named file that is a symlink, an excluded directory that cannot be listed or holds a directory link or a non-regular .php entry, a namespace name that does not end within 1 KiB, a declaration-shaped namespace statement the scan does not recognise, or a run that exceeds a limit (2,000 file reads, 8 MiB read, 20,000 inspected entries inside removed directories).
  • regex values, as before.
  • Every namespace value, when a file failed analysis: the run did not see all declared namespaces.
  • Every other withholding cause (omitted PHP paths, an unknown project universe, a missing PSR-4 map) withholds all values, as before.
  • The architecture and cohesion/coupling namespace audits (architecture.*, cohesion.unmatched-exclude-method, coupling.unmatched-framework-namespace) are not narrowed: any removed PHP file still withholds them, because they need class and method names, not only namespaces.

check prints a warning on stderr when removed code (an exclusion or generated filtering) alone kept literal namespace suppression values from being judged. It names every exclusion with PHP evidence (declarations cannot be attributed per selector) and mentions other removal unnamed as "an exclude entry", the number of values and their channels, and -q silences it. Regex values, values unjudged for another reason and literals the analysed namespaces already bind are not counted. A literal that a namespace of the analysed code already matches is judged even beside removed code. The advice depends on the cause:

Warning: Code removed by exclude "exact:src/Kernel.php" may declare the namespaces of 1 suppression value, so it was not judged (suppression.unmatched-namespace). Narrow the exclusion to judge it.
Warning: Code removed by exclude "exact:src/Kernel.php" could not be read in full (too many files, an unreadable file or a link) and may declare the namespaces of 1 suppression value, so it was not judged (suppression.unmatched-namespace). Exclude fewer files (and no unreadable file or link) to judge it.
Warning: Code removed by the generated-file policy may declare the namespaces of 1 suppression value, so it was not judged (suppression.unmatched-namespace). Pass --include-generated to judge it.

With both an exclusion and generated files the subject reads "exclude "…" and the generated-file policy" and the advice "Narrow the exclusion and/or pass --include-generated to judge it". An exclusion that removed no PHP, or removed code outside the measured universe, is not named: it reads "an exclude entry" (or "another exclude entry" beside a named one). A --suppress-namespace value given to check counts like a configured one. Several values read "N suppression values, so they were not judged" and "to judge them".

directives and baseline:* print no such warning; directives still names suppression.unmatched-namespace in its project scope. The report's projectScope.unjudgedValues lists every value left unjudged in all formats.

They are not written into a generated baseline: baseline:generate measures findings on a different seam, and a warning about the author's own configuration should not become accepted debt in the file that author generates with one command.

Every finding ends by naming the configuration that wrote the value — Written by configuration file "vendor/acme/std/qmx.yaml" (imported by configuration file "qmx.yaml"). — so a value that came from a shared base is told apart from one written in the project.

A value that matches nothing and that only files a Composer package installed wrote — a base imported from the analysed project's install directory (vendor/ unless config.vendor-dir says otherwise), whether the package is a copy or a symlinked path repository — is not judged when the project cannot remove it: a global suppress_paths or suppress_namespaces entry (a later layer adds to these lists and never takes away) or a value of a rule's suppress_namespace_channels entry (which cannot be emptied). The value is listed in projectScope.unjudgedValues, with an installed-configuration reason naming the file. A value that binds, one the project also writes, and one from the command line are judged as usual, and so are a rule's suppress_paths and suppress_namespaces: writing that rule's list in your own file, [] included, replaces the base's.

If a value is correct and cannot be corrected — a qmx.yaml shared across repositories some other way than as an installed package, naming a path one of them does not have — there are two ways out, and they are not the same:

  • Switch the channel off where that shared configuration lives: disabled_rules: ['suppression.unmatched-path'] silences that one channel and leaves the other two speaking. --disable-rule does the same for one run.
  • Accept it in a baseline you write by hand. A written entry naming the channel and the finding's occurrence under project: is honoured like any other. Because the value is part of that occurrence, the acceptance names that value: replacing it with a different unbound one is reported rather than passing under the accepted entry.

The channels

Channel What it detects
suppression.unmatched-path A global suppress_paths value matching no analysed file
suppression.unmatched-namespace A global suppress_namespaces value matching no declared namespace
suppression.unmatched-rule-ledger The same, for a value configured under rules.<name> — suppress_namespace_channels included, reported under the selector it was written beneath

The rule-ledger channel is separate because the mistake it catches has its own shape: a per-rule suppressor is written next to the rule it belongs to, so a value that outlived its subject stays legible in context long after it stopped naming anything.

Example

# qmx.yaml
suppress_namespaces:
  - exact: App\Legacy\Importer   # the namespace was renamed to App\Import
[project] suppression.unmatched-namespace
  The suppress_namespaces pattern "App\Legacy\Importer" matched no namespace
  declared in this run, so it suppressed nothing and could not have. If the
  code it was written for still exists under another spelling, its findings
  are being reported.

Options

Option Default Description
enabled true Enable or disable the rule and all its channels.

Configuration

# qmx.yaml
rules:
  suppression.configuration:
    enabled: true
bin/qmx check src/ --disable-rule=suppression.configuration

Owner and namespace membership

A suppress_namespace_channels key must address a channel of its configured producer at Namespace level. One channel must witness both owner membership and the applied level; a level from a sibling channel cannot justify it. Write the keyed map in YAML. The audit itself uses final selection and keeps its existing whole/partial-coverage limits. enabled:true is an intentional exact enable over a lower disable.