Skip to content

PHP: enum- and trait-typed receivers bypass the interface refusal and bind an unrelated same-short-named class #12

Description

@filipechagas

Parent

Spec: #1 (upstream: Graphify-Labs#1682). Found by the red-team session against 2e68ec6 (tickets #2-#6 + #8). Repro scripts: /tmp/rt6/ (harness.py, wave1.py, a4.py, wave2.py, wave3.py, nodes.py).

Problem

This is the defect verifier-2 raised for interfaces and #5 fixed — left open for the other two PHP declaration kinds that mint no definition node.

An enum-typed receiver resolves to a completely unrelated class that merely shares its short name, at INFERRED 0.8.

// app/Status.php
namespace App;
enum Status: string {
    case Active = 'a';
    public function label(): string { return 'ENUM'; }
}

// app/Legacy/Status.php
namespace App\Legacy;
class Status { public function label(): string { return 'WRONG'; } }

// app/Runner.php
namespace App;
class Runner {
    private Status $status;                                   // the ENUM
    public function go(): void { $this->status->label(); }
}

Actual:

app/Runner.php:.go()  ->  app/Legacy/Status.php:.label()   INFERRED 0.8

Expected: refuse. App\Status is an enum; App\Legacy\Status is a stranger.

Mechanism

Two independent gaps line up:

  • _PHP_CONFIG.class_types is frozenset({"class_declaration"}) (extract.py:737). enum_declaration and trait_declaration are absent — note the TS, Java and C# configs do include enum_declaration (extract.py:755, 796, 872).
  • _php_pre_scan_interfaces (engine.py:146) matches only interface_declaration, so enum and trait names never reach the refusal set consumed at extract.py:3168.

So an enum name is absent from both type_def_nids and interface_names: it cannot resolve, and it cannot trigger a refusal. Any same-short-named class is then the single visible definition and sails through the len(type_defs) != 1 god-node guard.

Node dump (/tmp/rt6/nodes.py) shows the enum is structurally invisible — no type node, no method edge:

nodes (type | label | source_file | contained?):
   None | Status.php  | app/Status.php  | -
   None | label()     | app/Status.php  | CONTAINED     <- file-level function, no method edge
   None | Klass.php   | app/Klass.php   | -
   None | Klass       | app/Klass.php   | CONTAINED     <- class DOES mint a type node
   None | .label()    | app/Klass.php   | -

method edges: [('..._klass', '..._klass_label')]      <- class only

The enum's label() becomes a file-level function node rather than a method on a type.

All four receiver entry points leak

Verified in /tmp/rt6/wave3.py; every one produces the wrong edge:

Entry point Fixture Result
Typed property private Status $status; wrong edge (wave1.py::A4)
Promoted ctor param __construct(private Status $s) wrong edge (F1_promoted)
Typed method param go(Status $s) wrong edge (F1_typed_param)
FQN-typed property private \App\Status $s; wrong edge (F1_fqn_prop)

The FQN variant is the sharpest: the source names the enum unambiguously and the resolver still binds the stranger.

Traits: same root cause

/tmp/rt6/wave2.py::B1_trait_vs_classtrait App\Cache + unrelated class App\Legacy\Cache + private Cache $cache yields go() -> Legacy/Cache.php:.flush(). Lower realism (a trait is not a valid type, so the fixture is already broken PHP), but it is the same missing-declaration-kind hole and a fix should cover it.

Controls (guards that DO hold)

  • Enum alone, no colliding class → no edge (a4.py::A4_control_enum_only) — proves the enum is unresolvable, so the collision supplies the only candidate.
  • Two colliding classes → no edge (a4.py::A4_control_two_classes) — the god-node guard works; enums simply don't participate in it.
  • interface MAILER + class Mailer, case-mismatched → refused (wave2.py::B2_iface_case) — PHP: interface-typed receivers refuse, even under short-name collision #5's case-insensitive interface refusal is sound.

Realism

High. An enum mirroring a model is standard Laravel: App\Enums\Role beside an Eloquent App\Models\Role, App\Enums\Status beside App\Models\Status. PHP 8.1 enums with methods are idiomatic, and enum-typed properties are exactly how they are consumed.

What to build

Close both halves so enums and traits can neither be mis-bound nor silently ignored. Either:

  • (a) extend _php_pre_scan_interfaces into a general "non-class declaration" pre-scan covering enum_declaration and trait_declaration, threading the names to the resolver so they are refused alongside interfaces (minimal, matches the shipped PHP: interface-typed receivers refuse, even under short-name collision #5 pattern); or
  • (b) add enum_declaration to _PHP_CONFIG.class_types so enums mint real type nodes and method edges, letting the existing single-definition guard see the collision and refuse — this also fixes the recall gap below. Traits still need (a), since a trait is not a type.

(b) is the larger change but strictly better for enums; (a) alone leaves enum methods unresolvable forever.

Acceptance criteria

  • Enum-typed receiver with an unrelated same-short-named class yields no edge, across all four entry points (typed property, promoted param, typed param, FQN-typed property)
  • Trait-typed receiver with an unrelated same-short-named class yields no edge
  • Existing interface refusal and its case-insensitivity unchanged (test parity with PHP: interface-typed receivers refuse, even under short-name collision #5)
  • Enum alone still yields no wrong edge; two colliding classes still refuse
  • Full test suite green

Changelog note for #7 (recall gap)

Independent of the wrong edge above, enum methods are not resolvable as call targets at all: an enum mints no type node and no method edge, so $enumTyped->method() can never resolve even with no collision present. If the fix takes route (a), this stays true and should be named in the changelog next to the other deliberate refusals (interface-typed, union, untyped, docblock-only, chained, trait and inherited methods). If it takes route (b), enum calls become resolvable and that is a recall improvement worth naming instead.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingready-for-agentFully specified, ready for an AFK agent

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions