Skip to content

v1.3.0 - orphan detection improvements

Choose a tag to compare

@lucianofedericopereira lucianofedericopereira released this 18 Aug 20:03
· 6 commits to main since this release

[1.3.0] - 2026-08-18

Fixed — orphan detection: block-structure tracking and aliased imports

Three unrelated PHP constructs desynced the symbol collector's context stack. A desynced stack
silently corrupted every declaration after it in the same file: methods were recorded as global
functions, and references inside skipped spans were lost — so live code was reported as a
definite orphan. Because orphans ride along in the default scan as an advisory, this affected
every run, not only --orphans.

  • Closure capture clausesfunction () use ($x) { ... } was treated as an import statement and
    skipped to the next ;, which lands inside the closure body. Every reference in that span was
    lost, and because the skip bypassed the closure's own {, the stack stayed shallow for the rest
    of the file.
  • Anonymous classesnew class { ... } did not open a type body, so its methods were
    recorded as global functions and a use SomeTrait; inside it was skipped instead of counted as a
    trait reference.
  • Curly-brace string interpolation"{$var}" popped a block level that was never pushed:
    token_get_all() emits an array T_CURLY_OPEN token for the opening brace but a plain } string
    token to close it.

A fourth, separate cause was found while re-measuring the survivors:

  • Aliased importsuse A\B\Original as Alias; means the class is only ever written as
    Alias, so Original was never counted and a class used solely under an alias was reported as a
    definite orphan. Alias pairs are now resolved for single, comma-separated, grouped
    (use A\{B as C};) and use function ... as ... forms. An import whose alias is never used still
    counts nothing, so an unused import cannot mask a dead class.

Measured against third-party sources: Laravel Illuminate/Database went from 2150 symbols scanned
and 538 definite orphans to 249 and 13; Illuminate/Support from 494 and 144 to 147 and 21. The
findings that disappeared were phantoms — __clone and other methods reported as dead global
functions
— plus the four grammar classes Laravel imports under an alias.

Changed — an unreferenced trait is a possible orphan, not a definite one

A trait exists to be consumed by other classes, so a library ships traits for consumers that are
never part of the scan — Laravel's HasFactory and HasBuilder are the archetype. Traits now join
interfaces and abstract classes in the contract tier: still reported, but no longer failing the
build. Nothing is hidden — the total finding count is unchanged, only the confidence tier moves.
Across laravel/framework this shifts 13 findings, from 48 definite / 32 possible to 35 / 45.

Symbols that a source-only scan genuinely cannot resolve — a service provider discovered through
composer.json, a cast class named only in a downstream model — remain out of scope; @api /
@phpcpd-keep are the escape hatch for those.

Added SymbolCollectorContextTest, which pins each construct plus three regression guards (a
brace-delimited namespace import must stay un-referenced; ::class must not be read as a
declaration; an unused aliased import must credit nothing), and a dogfooding invariant: phpcpd's own
src/ declares no global functions.