Skip to content

Code Smell Rules

Code smells are patterns that suggest something might be wrong with your code. They are not necessarily bugs, but they indicate areas where the code could be improved. These rules detect common bad practices that should usually be avoided.

All code smell rules can be individually enabled or disabled.

The analysis evidence, occurrence decoding, and rules are co-located in the Analysis\Evidence\CodeSmell capability. Rule IDs and configuration remain unchanged.

Findings inside named declarations carry the class, method, or function symbol; file-scope and anonymous evidence retain a file symbol and null namespace.

Function names are matched as they are written in the call. Imports are not resolved, so a function called through an alias (use function var_dump as vd; vd($x);) is not recognized by the rules that look for a function by name (debug-code, count-in-loop).


Boolean Arguments

Rule ID: code-smell.boolean-argument Severity: Warning

What it measures

Detects methods that accept bool parameters. A boolean argument usually means the method does two different things depending on the flag, which violates the Single Responsibility Principle.

The evidence is the declared type: bool, ?bool, or a union containing bool. An untyped parameter is not flagged, even with a boolean default ($overwrite = false) or a @param bool PHPDoc.

Promoted constructor properties (public function __construct(public bool $x)) are not flagged by default: a promoted parameter declares a field, not a behavior switch, so "split into two methods" doesn't apply to it. Set flag_promoted_properties: true to flag them too.

Example

// Bad: what does `true` mean here? Hard to read at the call site.
$user->save(true);

// The method signature reveals the problem:
public function save(bool $sendNotification): void
{
    // ...saves the user...
    if ($sendNotification) {
        // ...sends notification...
    }
}

How to fix

  1. Split into two methods with descriptive names:

    public function save(): void { /* ... */ }
    public function saveAndNotify(): void { /* ... */ }
    
  2. Use an enum if there are more than two options:

    enum SaveMode {
        case Silent;
        case WithNotification;
        case WithAuditLog;
    }
    
    public function save(SaveMode $mode = SaveMode::Silent): void { /* ... */ }
    

Configuration

# qmx.yaml
rules:
  code-smell.boolean-argument:
    allowed_prefixes: [is, has, can, should, will, did, was]  # defaults
    flag_promoted_properties: false                          # default

Boolean parameters whose names start with one of these prefixes are considered self-documenting and are not flagged. For example, $isActive, $hasPermission, or $is_active (snake_case) would all be allowed with the default prefixes. Set to [] to flag all boolean parameters.

A single string stands for a one-element list: allowed_prefixes: is means exactly allowed_prefixes: [is]. A digit string is a prefix like any other, so allowed_prefixes: "2" is the one-element list ["2"].

flag_promoted_properties controls whether promoted constructor properties (public bool $x) are flagged like regular boolean arguments. Defaults to false, since a promoted parameter is a field declaration, not a behavior switch.

# A CLI door carries one scalar, so this sets the single prefix `is` -- the
# text after `=` is never split. Several prefixes are written as a list in qmx.yaml.
bin/qmx check src/ --rule-opt="code-smell.boolean-argument:allowed_prefixes=is"
bin/qmx check src/ --rule-opt="code-smell.boolean-argument:flag_promoted_properties=true"

count() in Loop

Rule ID: code-smell.count-in-loop Severity: Warning

What it measures

Detects calls to count() (or sizeof()) inside loop conditions. When count() is in the loop condition, it gets recalculated on every iteration, which is wasteful.

Example

// Bad: count() runs on every iteration
for ($i = 0; $i < count($items); $i++) {
    processItem($items[$i]);
}

How to fix

Store the count in a variable before the loop:

// Good: count() runs once
$itemCount = count($items);
for ($i = 0; $i < $itemCount; $i++) {
    processItem($items[$i]);
}

// Even better: use foreach when possible
foreach ($items as $item) {
    processItem($item);
}

Debug Code

Rule ID: code-smell.debug-code Severity: Error

What it measures

Detects debugging functions left in production code: var_dump(), print_r(), var_export(), dd(), dump(), debug_print_backtrace(), and debug_zval_dump().

Return mode (var_export($value, true), print_r($value, return: true)) is exempt only for print_r() and var_export(). A positional or named true does not exempt var_dump(), dd(), or dump(). Calls inside debug API methods such as dump() or debug() are also flagged; use a reasoned @qmx-ignore code-smell.debug-code for an intentional call.

debug_backtrace() is not flagged: it returns the trace instead of printing it and is a common part of error handling.

Example

public function processPayment(Order $order): void
{
    var_dump($order);          // forgotten debug output
    print_r($order->items);   // forgotten debug output

    // actual logic...
}

How to fix

Remove all debug statements before committing. If you need to inspect data:

  • Use a proper logger ($this->logger->debug(...))
  • Use a debugger (Xdebug)
  • Use a profiling tool

Warning

Debug output can leak sensitive information (passwords, tokens, personal data) to end users. This is why it is reported as Error, not Warning.


Empty Catch

Rule ID: code-smell.empty-catch Severity: Error

What it measures

Detects catch blocks that are completely empty -- they catch an exception and do absolutely nothing with it. This silently swallows errors, making bugs extremely hard to diagnose.

A catch that holds only a comment is still empty: the comment explains the silence but does not end it.

Chain of attempts. One shape is not flagged: a foreach that tries each item until one succeeds. The try must be a direct statement of the loop body and must be able to end the search after successful work: it holds a return or break, or a continue that skips statements following the try -- at its top level or inside its if branches:

foreach ($this->resolvers as $resolver) {
    try {
        return $resolver->resolve($key);  // success ends the search
    } catch (ResolverException $e) {
        // failure means "try the next resolver"
    }
}

A try nested deeper in the loop body, one inside a closure, or one whose only way out is a continue with nothing after the try (so it skips nothing) is flagged as usual. A direct, top-level if in the try is treated as an early guard only when its then-branch contains exactly one bare continue and later executed work follows in the same try; other branches keep their ordinary attempt semantics. A break, return, extra then-branch statement, or guard placed in an else/elseif or nested if is not treated as an early guard by this rule: each can be the successful exit of a genuine chain of attempts. The other rules of the exemption still apply, so such a branch with no preparatory work before it is judged as usual.

Example

// Bad: exception is silently ignored
try {
    $this->sendEmail($user);
} catch (\Exception $e) {
    // nothing here -- if sending fails, nobody will know
}

How to fix

  1. Log the exception:

    try {
        $this->sendEmail($user);
    } catch (\Exception $e) {
        $this->logger->error('Failed to send email', ['exception' => $e]);
    }
    
  2. Rethrow as a domain exception:

    try {
        $this->sendEmail($user);
    } catch (\Exception $e) {
        throw new NotificationFailedException('Email sending failed', previous: $e);
    }
    
  3. Handle the error explicitly if it is expected and recoverable.

  4. Suppress it with a reason if ignoring the exception is intentional: a comment in the catch does not clear the finding, @qmx-ignore code-smell.empty-catch -- <reason> does.


Error Suppression

Rule ID: code-smell.error-suppression Severity: Warning

What it measures

Detects use of the @ error suppression operator. The @ operator hides PHP errors and warnings, making it harder to find and fix problems.

Example

// Bad: if the file doesn't exist, you won't know why things fail later
$data = @file_get_contents('/path/to/file');

// Bad: suppressing warnings from a function
$result = @json_decode($input);

How to fix

Handle errors explicitly:

// Good: check before calling
if (!file_exists($path)) {
    throw new FileNotFoundException($path);
}
$data = file_get_contents($path);

// Good: use error handling functions
$result = json_decode($input, flags: JSON_THROW_ON_ERROR);

Configuration

# qmx.yaml
rules:
  code-smell.error-suppression:
    allowed_functions:  # default: []
      - fopen
      - file_get_contents
      - unlink

Functions where @ error suppression is acceptable. Some I/O functions return false and emit a warning on failure -- using @ with them is a common practice when you check the return value explicitly. By default, no functions are allowed.

A single string stands for a one-element list: allowed_functions: fopen means exactly allowed_functions: [fopen]. A digit string is a name like any other.

# A CLI door carries one scalar, so this allows the single function `fopen` --
# the text after `=` is never split. Several names are written as a list in qmx.yaml.
bin/qmx check src/ --rule-opt="code-smell.error-suppression:allowed_functions=fopen"

eval()

Rule ID: code-smell.eval Severity: Error

What it measures

Detects use of eval(), which executes arbitrary PHP code from a string. This is a serious security risk: if user input reaches eval(), an attacker can run any code on your server.

Example

// Bad: security vulnerability
$formula = $_GET['formula'];
$result = eval("return $formula;");

// Bad: even with "safe" input, eval is hard to debug and maintain
eval('$config = ' . var_export($data, true) . ';');

How to fix

  • Use closures or callable objects instead of generating code as strings.
  • Use the Strategy pattern instead of dynamic code execution.
  • Use json_decode() for data parsing, not eval().
  • Use template engines for generating dynamic output.

exit() / die()

Rule ID: code-smell.exit Severity: Warning

What it measures

Detects use of exit() and die(), including the fully qualified function-call spelling \exit() / \die() that PHP 8.4 introduced. These functions terminate the entire PHP process immediately, which:

  • Prevents proper error handling
  • Makes the code untestable (PHPUnit cannot catch exit)
  • Bypasses shutdown handlers and destructors

Example

// Bad: untestable, no error handling
if (!$user->isAdmin()) {
    die('Access denied');
}

// Bad: prevents proper response handling
if ($error) {
    exit(1);
}

How to fix

Throw exceptions instead:

// Good: can be caught, tested, and handled
if (!$user->isAdmin()) {
    throw new AccessDeniedException('Access denied');
}

Note

exit() is acceptable in CLI entry points (e.g., bin/console) where it sets the process exit code. This rule is mainly about using exit/die inside application logic.


goto

Rule ID: code-smell.goto Severity: Error

What it measures

Detects use of goto statements. goto makes control flow unpredictable -- the reader has to search for the target label, which can be anywhere in the function. This makes the code very hard to follow and debug.

Example

// Bad: spaghetti control flow
function process(array $items): void
{
    foreach ($items as $item) {
        if ($item->isInvalid()) {
            goto cleanup;
        }
        // process item...
    }

    cleanup:
    // cleanup code
}

How to fix

Use standard control flow structures:

// Good: clear control flow
function process(array $items): void
{
    foreach ($items as $item) {
        if ($item->isInvalid()) {
            $this->cleanup();
            return;
        }
        // process item...
    }
}

Use loops, functions, early returns, or exceptions -- they all express intent more clearly than goto.


Superglobals

Rule ID: code-smell.superglobals Severity: Warning

What it measures

Detects direct access to PHP superglobal variables: $_GET, $_POST, $_REQUEST, $_SERVER, $_SESSION, $_COOKIE, $_FILES, $_ENV, and $GLOBALS. Literal variable-variable names (${'_GET'}, ${'_'.'GET'}) and literal $GLOBALS['_GET'] reads are detected too. An unknown dynamic name such as $$name cannot be identified.

Direct superglobal access creates hidden dependencies on the global state, making code hard to test and unpredictable.

Example

// Bad: tightly coupled to global state
class UserController
{
    public function register(): void
    {
        $name = $_POST['name'];
        $email = $_POST['email'];
        $ip = $_SERVER['REMOTE_ADDR'];
        // ...
    }
}

How to fix

Use dependency injection with request objects:

// Good: dependencies are explicit and testable
class UserController
{
    public function register(ServerRequestInterface $request): ResponseInterface
    {
        $body = $request->getParsedBody();
        $name = $body['name'];
        $email = $body['email'];
        $ip = $request->getServerParams()['REMOTE_ADDR'];
        // ...
    }
}

Common request abstractions:

  • PSR-7: ServerRequestInterface (framework-agnostic)
  • Symfony: Request from HttpFoundation
  • Laravel: Illuminate\Http\Request

Constructor Over-injection

Rule ID: code-smell.constructor-overinjection

Judged metric: code-smell.parameter-count

What it measures

Detects constructors with too many dependencies injected. A long constructor parameter list in DI-heavy codebases is a direct signal of Single Responsibility Principle violation -- the class has too many collaborators.

Every constructor has exactly one owner. This rule owns each __construct of a class, abstract class, interface, trait or anonymous class (the name is matched case-insensitively) that is not a value-object constructor. A value-object constructor carries fields, not dependencies, and is not judged by this rule: code-smell.long-parameter-list judges it with its VO thresholds. The JSON report lists such constructors under abstentions with the gate value-object-constructor.

Disabling this rule, or excluding it with --only-rule, leaves the constructors it owns unjudged: code-smell.long-parameter-list does not take them over. A global function named __construct has no class and stays with long-parameter-list.

Thresholds

Value Severity Meaning
8 Warning Too many dependencies, consider splitting
12+ Error Class clearly violates SRP

Example

// Bad: constructor depends on too many services
class OrderProcessor
{
    public function __construct(
        private UserRepository $users,
        private ProductRepository $products,
        private InventoryService $inventory,
        private PricingService $pricing,
        private TaxCalculator $tax,
        private ShippingService $shipping,
        private NotificationService $notifications,
        private AuditLogger $audit,
        private MetricsCollector $metrics,
    ) {}
}

How to fix

  1. Split the class into smaller, focused services (e.g., OrderValidator, OrderFulfiller, OrderNotifier).
  2. Use a Facade or Mediator to group related dependencies behind a single interface.
  3. Introduce a Command Bus -- instead of injecting handlers directly, dispatch commands.

Configuration

# qmx.yaml
rules:
  code-smell.constructor-overinjection:
    warning: 8
    error: 12
bin/qmx check src/ --rule-opt="code-smell.constructor-overinjection:warning=6"

For a simple pass/fail threshold instead of separate warning/error levels (threshold cannot be combined with warning or error — mixing them is a configuration error and the run stops with exit code 3):

rules:
  code-smell.constructor-overinjection:
    threshold: 8   # warning=8, error=8 → all violations are errors
bin/qmx check src/ --rule-opt="code-smell.constructor-overinjection:threshold=8"

Long Parameter List

Rule ID: code-smell.long-parameter-list

Judged metric: code-smell.parameter-count

What it measures

Detects methods and functions with too many parameters. A long parameter list makes the method hard to call correctly, hard to test, and often indicates the method is doing too many things. Consider using a parameter object or splitting the method.

Of all constructors, this rule judges only value-object constructors. Any other constructor belongs to code-smell.constructor-overinjection, so one constructor is never reported by both rules; the JSON report lists it under abstentions with the gate injection-constructor. Disabling constructor-overinjection therefore leaves those constructors unjudged. A global function named __construct is a function, not a constructor, and uses the standard thresholds.

Thresholds

Standard thresholds (methods and functions, constructors excluded):

Value Severity Meaning
4 Warning Consider grouping parameters
6+ Error Too many parameters, refactor needed

VO constructor thresholds (readonly class, all promoted, empty body, state exposed):

Value Severity Meaning
8 Warning Consider splitting the value object
12+ Error Too many fields, split the value object

Value Object constructor exemption

Readonly classes with constructor parameter promotion (PHP 8.2+) are treated as Value Objects. A readonly class with 6 promoted properties and no body logic is valid design — it's a typed data container, not a sign of poor decomposition. These constructors use separate, higher thresholds.

VO detection criteria (all must be true):

  1. Class has the readonly modifier
  2. All constructor parameters are promoted properties (carry a visibility, readonly or asymmetric set modifier)
  3. Constructor body is empty (no statements)
  4. The class exposes at least one of those properties: it is publicly readable (public, or only readonly / private(set)), or a method of the class itself returns it exactly as return $this->name;

When any condition is not met, the constructor is not a value-object constructor and code-smell.constructor-overinjection judges it with its own thresholds (8/12); long-parameter-list abstains.

The fourth criterion separates a data carrier from a readonly service. A final readonly class whose promoted properties are all private and never returned holds injected dependencies, not data, and is judged like any other constructor. A getter that returns a derived value ($this->name->value, ['name' => $this->name]) does not count as exposure; neither does a return inside a closure or arrow function, which is how services hand fn() => $this->dependency to lazy factories, nor a getter declared in a used trait. A value object whose state is reachable only that way is judged by code-smell.constructor-overinjection. A @qmx-threshold code-smell.long-parameter-list on a constructor that rule owns has nothing to tune: use code-smell.constructor-overinjection there.

Example

// Bad: too many parameters, hard to remember the order
public function createUser(
    string $name,
    string $email,
    string $phone,
    string $address,
    string $city,
    string $country,
    string $zipCode,
): User {
    // ...
}
// OK: readonly VO with promoted properties uses higher thresholds
final readonly class CreateUserRequest
{
    public function __construct(
        public string $name,
        public string $email,
        public string $phone,
        public string $address,
        public string $city,
        public string $country,
        public string $zipCode,  // 7 params — below vo-warning=8
    ) {}
}

How to fix

  1. Use a parameter object (DTO):

    final readonly class CreateUserRequest
    {
        public function __construct(
            public string $name,
            public string $email,
            public string $phone,
            public Address $address,
        ) {}
    }
    
    public function createUser(CreateUserRequest $request): User { /* ... */ }
    
  2. Split the method if parameters belong to different responsibilities.

Configuration

# qmx.yaml
rules:
  code-smell.long-parameter-list:
    warning: 4        # methods and functions
    error: 6
    vo-warning: 8     # readonly VO constructors
    vo-error: 12
bin/qmx check src/ --rule-opt="code-smell.long-parameter-list:warning=5"
bin/qmx check src/ --rule-opt="code-smell.long-parameter-list:error=8"
bin/qmx check src/ --rule-opt="code-smell.long-parameter-list:vo-warning=10"
bin/qmx check src/ --rule-opt="code-smell.long-parameter-list:vo-error=15"

For a simple pass/fail threshold instead of separate warning/error levels, on either axis independently:

rules:
  code-smell.long-parameter-list:
    threshold: 6       # warning=6, error=6 → all standard-method violations are errors
    vo_threshold: 12   # warning=12, error=12 → all VO-constructor violations are errors
bin/qmx check src/ --rule-opt="code-smell.long-parameter-list:threshold=6"
bin/qmx check src/ --rule-opt="code-smell.long-parameter-list:vo_threshold=12"

Identical Sub-expression

Rule ID: code-smell.identical-subexpression Severity: Warning

What it measures

Detects identical sub-expressions that indicate copy-paste errors or logic bugs. The rule catches five patterns:

  1. Identical operands in binary operations (identical_operands) -- the same expression on both sides of an operator (e.g., $a === $a, $a - $a, $a && $a).
  2. Duplicate conditions in if/elseif chains (duplicate_condition) -- the same condition checked more than once, meaning the second branch is dead code. An else that holds nothing but another if (else if, or else { if (...) }) continues the chain.
  3. Identical ternary branches (identical_ternary) -- a ternary where the "true" and "false" branches are the same, making the condition pointless.
  4. Duplicate match arm conditions (duplicate_match_arm) -- repeated conditions in a match expression, where only the first arm will ever execute.
  5. Duplicate switch cases (duplicate_switch_case) -- the same case value repeated in a switch, where only the first case is ever entered.

Operators with legitimate identical-operand use cases are not flagged: +, *, **, ., &, |, <<, >>.

Side effects are treated differently per pattern:

  • Operands and ternary branches with side effects (function calls, method calls, assignments, increments, etc.) are excluded, since two evaluations may produce different results.
  • Conditions (patterns 2, 4 and 5) are compared including calls: a repeated call in a condition chain is almost always a copy-paste error, like the $order->isPaid() example below. A chain that deliberately repeats a side-effecting call ($stack->pop()) is reported and needs an inline suppression with a reason.

Example

class OrderService
{
    public function validate(Order $order): bool
    {
        // Bad: identical operands -- always true, likely a typo
        if ($order->total === $order->total) {
            // ...
        }

        // Bad: subtracting a value from itself -- always 0
        $diff = $order->price - $order->price;

        // Bad: duplicate condition -- second branch is dead code
        if ($order->isPaid()) {
            return true;
        } elseif ($order->isPaid()) {
            return false;
        }

        // Bad: identical ternary branches -- condition is pointless
        $status = $order->isActive() ? 'pending' : 'pending';

        // Bad: duplicate match arm condition
        return match ($order->type) {
            'retail' => $this->handleRetail($order),
            'wholesale' => $this->handleWholesale($order),
            'retail' => $this->handleSpecial($order),  // never reached
        };
    }
}

How to fix

These are almost always bugs -- inspect each occurrence and fix the intended logic:

  1. Identical operands: One side is usually a typo. Replace it with the correct variable:

    // Was: $order->total === $order->total (always true)
    // Fix: compare with the expected value
    if ($order->total === $order->expectedTotal) {
        // ...
    }
    
  2. Duplicate conditions: Remove the duplicate branch or fix the condition:

    if ($order->isPaid()) {
        return true;
    } elseif ($order->isRefunded()) {
        return false;
    }
    
  3. Identical ternary branches: Either the condition is unnecessary, or one branch has a wrong value:

    $status = $order->isActive() ? 'active' : 'pending';
    
  4. Duplicate match arms: Remove the duplicate or fix the condition value.

  5. Duplicate switch cases: Remove the unreachable case or fix its value.


Unused Private Members

Rule ID: code-smell.unused-private

Judged metric: code-smell.unused-private.total Severity: Warning

What it measures

Detects private methods, properties, and constants that are declared but never referenced within the class. Unused private members are dead code -- they add noise, increase cognitive load, and may indicate incomplete refactoring.

The rule is smart about edge cases:

  • Magic method awareness: classes with __call/__callStatic skip method checks; classes with __get/__set skip property checks. The magic method counts whatever its visibility and letter case, and also when a trait used from the same file declares it
  • Constructor promotion: promoted properties are tracked correctly
  • Anonymous classes: private members in anonymous classes are isolated and don't leak to the parent class
  • Analyzed types: classes and enums; interfaces and traits are not analyzed
  • Access patterns: recognizes $this->method(), self::method(), static::method(), property access, and constant access. Method names are matched case-insensitively, as PHP resolves them; property and constant names are case-sensitive
  • Callables: a literal callable array counts as a use of the method: [$this, 'method'], [self::class, 'method'], [static::class, 'method'], [__CLASS__, 'method'], also inside Closure::fromCallable() or array_map(). Literal callable strings naming the same class, such as 'self::method' or 'App\Subject::method', count too. A method named by a variable ([$this, $name], $this->$name()) remains unknown; short callable strings such as 'Subject::method' are not resolved through the current namespace
  • Recursion: a private method that is only called from its own body is reported as unused; a callable array naming the method inside its own body is a self-reference too
  • Trait resolution: calls to methods defined in traits used by the same class (in the same file) are recognized, reducing false positives

Example

class OrderService
{
    private string $unusedField = '';  // never read or written

    private const LEGACY_LIMIT = 100;  // never referenced

    public function process(Order $order): void
    {
        // ... uses $order but never touches $unusedField or LEGACY_LIMIT
    }

    private function oldHelper(): void  // never called
    {
        // leftover from a previous implementation
    }
}

How to fix

  • Remove the unused member if it is truly dead code.
  • Change visibility to protected or public if the member is used by subclasses or external code.
  • If the member is intentionally kept, place @qmx-ignore code-smell.unused-private on that member. It reaches findings on the member's lines only; a promoted parameter also reaches its corresponding property finding. See member reach.

Unreachable Code

Rule ID: code-smell.unreachable-code

Judged metric: code-smell.unreachable-code

What it measures

Detects code that can never be executed because it appears after a terminal statement (return, throw, exit/die, continue, break, goto). Dead code adds noise, confuses readers, and may indicate a logic error.

Every statement list of a method, function, closure or property hook is checked on its own: the body itself and each block nested in if/elseif/else, loops, try/catch/finally, switch cases and plain blocks. A dead statement is counted once, without looking inside it, and a goto label makes the statements after it reachable again. A break placed after a return in a switch case is unreachable too and is counted.

Terminality is decided per statement list, without flow analysis across branches: code after an if/else whose every branch returns is not reported.

Example

public function process(Order $order): string
{
    if ($order->isPaid()) {
        return 'processed';
    }

    return 'pending';

    // Bad: this code can never run
    $this->logger->info('Processing complete');
    $this->notify($order);
}

How to fix

Remove the unreachable code. If the code was supposed to run, fix the control flow:

public function process(Order $order): string
{
    if ($order->isPaid()) {
        $this->logger->info('Processing complete');
        $this->notify($order);
        return 'processed';
    }

    return 'pending';
}

Configuration

# qmx.yaml
rules:
  code-smell.unreachable-code:
    warning: 1
    error: 1
bin/qmx check src/ --rule-opt="code-smell.unreachable-code:warning=1"
bin/qmx check src/ --rule-opt="code-smell.unreachable-code:error=1"

For a simple pass/fail threshold instead of separate warning/error levels (threshold cannot be combined with warning or error — mixing them is a configuration error and the run stops with exit code 3):

rules:
  code-smell.unreachable-code:
    threshold: 1   # warning=1, error=1 → all violations are errors
bin/qmx check src/ --rule-opt="code-smell.unreachable-code:threshold=1"

Configuration

All code smell rules share the same simple configuration -- just enable or disable:

# qmx.yaml
rules:
  code-smell.boolean-argument:
    enabled: true
    allowed_prefixes: [is, has, can, should, will, did, was]
    flag_promoted_properties: false
  code-smell.debug-code:
    enabled: true
  code-smell.empty-catch:
    enabled: true
  code-smell.eval:
    enabled: false    # disable if you have legitimate eval usage
  code-smell.exit:
    enabled: true
  code-smell.goto:
    enabled: true
  code-smell.superglobals:
    enabled: true
  code-smell.constructor-overinjection:
    warning: 8
    error: 12
  code-smell.count-in-loop:
    enabled: true
  code-smell.error-suppression:
    enabled: true
    allowed_functions: []
  code-smell.long-parameter-list:
    warning: 4
    error: 6
  code-smell.unreachable-code:
    warning: 1
    error: 1
  code-smell.unused-private:
    enabled: true
  code-smell.identical-subexpression:
    enabled: true

You can also disable individual rules via the --disable-rule CLI option:

# Disable a specific rule
bin/qmx check src/ --disable-rule=code-smell.exit

# Disable all code smell rules at once (wildcard match; matches descendants only, not "code-smell" itself)
bin/qmx check src/ --disable-rule=code-smell.*

Authored enabling and forms

A written enabled:true can reverse a lower preset disable; retain it when that is intended. Empty maps preserve lower options, and a rule true writes enabled only rather than resetting severity/options. All malformed lower writes refuse before discovery. Code-smell algorithms and fixed severities are unchanged. See Configuration.