v1.3.0 - orphan detection improvements
[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 clauses —
function () 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 classes —
new class { ... }did not open a type body, so its methods were
recorded as global functions and ause 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 arrayT_CURLY_OPENtoken for the opening brace but a plain}string
token to close it.
A fourth, separate cause was found while re-measuring the survivors:
- Aliased imports —
use A\B\Original as Alias;means the class is only ever written as
Alias, soOriginalwas 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};) anduse 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.