PHPantom — Diagnostics¶
Items are ordered by impact (descending), then complexity (ascending) within the same impact tier.
| Label | Scale |
|---|---|
| Impact | Critical, High, Medium-High, Medium, Low-Medium, Low |
| Complexity | Low (mechanical/boilerplate, no design decisions), Medium (self-contained, follows an existing pattern), Medium-High (spans modules, some new design), High (shared/core subsystem, correctness or performance tradeoffs), Very High (cross-cutting architecture, wide blast radius) |
Severity philosophy¶
PHPantom assigns diagnostic severity based on runtime consequences:
| Severity | Criteria | Examples |
|---|---|---|
| Error | Would crash at runtime. The code is definitively wrong. | Member access on a scalar type ($int->foo()). Calling a function that doesn't exist (doesntExist()). |
| Warning | Likely wrong but could work for reasons we can't verify statically. The types are poor but the code might be correct at runtime. | Accessing a member that doesn't exist on a non-final class ($user->grantAccess() where User has no such method but a subclass might). Unknown class in a type position (Class 'Foo' not found). Subject type resolved to an unknown class so members can't be verified. |
| Hint | The codebase lacks type information. Off by default or very subtle. Poorly typed PHP is so common that showing these by default would be noise for most users. Anyone who does care about type safety is likely running PHPStan already. Unless our engine becomes very strong, these diagnostics either expose our own inference gaps or bother users who never opted into static analysis. | mixed subject member access (opt-in via unresolved-member-access). Deprecated symbol usage (rendered as strikethrough). |
| Information | Advisory. Something the developer might want to know. | Unused use import (rendered as dimmed). Unresolved type in a PHPDoc tag. |
D5. External tool diagnostic suppression actions¶
Impact: Low · Complexity: Low (per tool, after proxy exists)
PHPantom's own inline suppression (// @phpantom-ignore code) has
shipped. PHPStan suppression is also implemented ("Ignore PHPStan
error" / "Remove unnecessary @phpstan-ignore"). The PHPCS proxy itself
has also shipped (src/diagnostics/external/phpcs.rs, [phpcs] config
section), but nothing wires up a suppression action for it yet. What
remains is wiring up suppression actions for additional external tool
proxies:
- PHPCS:
// phpcs:ignore [Sniff.Name]or// phpcs:disable/// phpcs:enableblocks. The proxy exists; only the suppression action is missing. - PHPMD (3.0):
#[SuppressWarnings(RuleName::class)]as a PHP attribute. Blocked on the proxy itself (D10).
D6. Unreachable code diagnostic¶
Impact: Low-Medium · Complexity: Medium
Dim code that appears after unconditional control flow exits:
return, throw, exit, die, continue, break. This is a
Phase 1 (fast) diagnostic since it requires only AST structure, not
type resolution.
Behaviour¶
| Scenario | Rendering |
|---|---|
Code after return $x; in same block |
Dimmed (DiagnosticTag::UNNECESSARY) |
Code after throw new \Exception() |
Dimmed |
Code after exit(1) or die() |
Dimmed |
Code after continue or break in a loop |
Dimmed |
Code after if (...) { return; } else { return; } |
Dimmed (both branches exit) |
Severity: Hint with DiagnosticTag::UNNECESSARY so editors dim
the text rather than underlining it. This matches how unused imports
are rendered.
Implementation¶
Walk the AST statement list. After encountering a statement that
unconditionally exits the current scope (return, throw, expression
statement containing exit/die), mark all subsequent statements in
the same block as unreachable. The span covers from the start of the
first unreachable statement to the end of the last statement in the
block.
Phase 1 only handles the simple single-block case. Whole-branch analysis (both if/else branches exit) is a future refinement.
Debugging value¶
When our type engine silently resolves a method to a never return
type (e.g. an incorrectly resolved overload), unreachable code after
the call becomes visible, signalling the bug.
D10. PHPMD diagnostic proxy¶
Impact: Low · Complexity: Medium
Proxy PHPMD (PHP Mess Detector) diagnostics into the editor, following
the same pattern as the existing PHPStan proxy. PHPMD 3.0 (once
released) is the target version. It will get a [phpmd] TOML section
with command, timeout, and tool-specific options mirroring the
[phpstan] schema.
Prerequisites¶
- PHPMD 3.0 must be released. Current 2.x output formats and rule naming may change.
- The diagnostic suppression code action (D5) can add PHPMD's
@SuppressWarnings(PHPMD.[RuleName])syntax once the proxy exists.
Implementation¶
- Add a
[phpmd]section to the config schema insrc/config.rswithcommand(default"vendor/bin/phpmd"),timeout, and anenabledflag. - Run PHPMD with XML or JSON output on the current file (or changed files) and parse the results into LSP diagnostics.
- Map PHPMD rule names to diagnostic codes so that suppression
actions (D5) can insert the correct
@SuppressWarningsannotation. - Respect the same debounce and queueing logic used by the PHPStan proxy to avoid overwhelming the tool on rapid edits.
D15. Unused parameter diagnostic¶
Impact: Low · Complexity: Medium
Flag function and method parameters that are never read inside the
body. This was intentionally excluded from D4 (unused variable
diagnostic) because false positives are common for callbacks, interface
implementations, and framework conventions (e.g. Laravel event
listeners) that require specific parameter signatures even when not
all parameters are used. Users can now silence false positives with
// @phpantom-ignore unused_parameter.
Scope¶
- Function and method parameters (including closures and arrow functions) that are never read inside their body.
- Constructor parameters that are not promoted and never read.
Exclusions¶
- Parameters named
$_or starting with$_(intentional discard). - Promoted constructor parameters (they are property assignments).
- Parameters in abstract methods and interface method signatures (no body to check).
D16. unreachable_match_arm ignores literal subject types¶
Impact: Low-Medium · Complexity: Medium
scalar_type_label in src/diagnostics/match_type_errors.rs answers
None for a literal type ('exception', 42), so a subject the
resolver typed as one exact value never reaches the arm check and no
arm is ever reported unreachable. The comment there explains why: a
literal was as often what survived after resolution lost an
alternative it could not type as it was a genuine one-value subject,
and taking the claim without that evidence produced false positives.
The resolver no longer loses those alternatives. An unresolvable branch now widens the union it belongs to instead of dropping out of it, so a literal that reaches this diagnostic is a claim the resolver stands behind.
Fix: Give TypeKind::Literal its scalar kind in
scalar_type_label and check the resulting arms. Cover the case the
old comment was guarding against with a test: a subject whose other
branch cannot be typed must still produce no diagnostic, because it
now resolves to mixed rather than to the surviving literal.
D17. docblock_native_mismatch only judges nullability¶
Impact: Low · Complexity: Medium-High
/** @param int $name */
function greet(?string $name): void {} // not flagged: int is not string at all
/** @param Foo $value */
function take(?Foo $value): void {} // not flagged: `Foo` may be nullable
src/diagnostics/docblock_native_mismatch.rs compares a documented type
against its native hint on one axis only: whether the annotation denies a
null the signature accepts. Two shapes are therefore still silent.
The first is a documented type that is not a subtype of the native hint at
all (@param int on a ?string, @return array on a : string). That is
the check PHPStan's IncompatiblePhpDocTypeRule performs, and the one the
existing is_type_compatible in src/diagnostics/type_errors/compatibility.rs
already has the machinery for.
The second is a bare class-like name, which nullability_is_decidable steps
around on purpose: Foo may be a @template parameter or an imported
@psalm-type alias that resolves to a nullable type, and the diagnostic has
no resolution step that would tell those apart from a class named Foo.
Resolving the name (against the declaration's own @template list, the
enclosing class's, and the file's @psalm-type/@psalm-import-type tags)
would let the nullability check cover the class-name case as well.
Fix: Resolve the documented type's names before comparing, then run the
comparison through is_type_compatible rather than the nullability test
alone. Both halves want the same measurement, so they are one change rather
than two.
D18. array<int, T> is accepted wherever a list<T> is declared¶
Impact: Low · Complexity: Medium-High
/**
* @param list<int> $values
* @return list<int>
*/
function keep(array $values): array {
return array_filter($values, fn ($v) => $v > 3); // array<int, int>, not flagged
}
is_type_compatible in src/diagnostics/type_errors/compatibility.rs
carries an explicit MAYBE hatch for array<int, X> reaching a list<X>
parameter or return type, on the grounds that PHP codebases spell the two
interchangeably. The core is_subtype_of already rejects the direction
(only list<X> satisfies array<int, X>, not the reverse), so the hatch
is the only thing standing between us and PHPStan's report here.
Now that array_filter() reports the array<int, T> it actually
produces, the hatch is what keeps the second half of the over-claim
alive: a function that hands back an unwrapped filter result still
passes a declared list<T>.
Fix: Drop the array<int, X> → list<X> arm and audit the corpus
under projects/ for what it starts reporting. The arm exists because
plain array<int, X> is what an unannotated array resolves to in many
places, so retiring it wants the resolver to answer list<X> for the
shapes that genuinely are lists (literal arrays, array_values(),
appended-to locals) first. Pay for it with resolver precision, the same
way the supertype-where-subtype hatch was retired.