Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ phptramp [options]
--format <fmt> text|pretty|json|github|checkstyle|sarif|summary (default: pretty on TTY, text otherwise)
--color <mode> always|auto|never (default: auto; honors NO_COLOR in auto mode)
--explain Show why chains ended (call resolution trace)
--exclude-terminal <kind> Do not report chains ending in <kind> (repeatable; used|stored|&-terminated|unused-end|external|truncated -- quote '&-terminated', or the shell backgrounds the command)
--exclude-terminal <kind> Do not report chains ending in <kind> (repeatable; used|stored|fan-out|&-terminated|unused-end|external|truncated -- quote '&-terminated', or the shell backgrounds the command)
--changed-only Only report chains touching changed lines
--git-base <ref> Diff base for --changed-only (default: origin/main)
--diff <path|-> Read the diff from a file, or stdin with '-' (implies --changed-only)
Expand Down
45 changes: 41 additions & 4 deletions docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,43 @@ implementation. Every bullet is a fixture.
method call on `$p`, array access, use in an expression, assignment to or from `$p`,
`return $p`, capture in a closure — makes the method a **terminal use** and ends the
chain there.

A method whose every occurrence of `$p` is a forward, but to two or more *distinct*
callees on one execution path, is a **fan-out terminal** (`fan-out`) rather than a
hop: it consumes the value by combining what those callees return (issue #26).
Forwards that reach distinct callees only in mutually exclusive arms (`if`/`elseif`/
`else`, `match`, `switch`, ternary) are not fan-out — a dispatcher picks one callee
per run and is a genuine pass-through — and the same callee twice is not fan-out
either. A branch *condition* (an `elseif` condition, a `match` arm's condition, a
`switch` case expression included) is never an arm: a forward there shares the path
with every arm. A `parent::` forward counts as a callee like any other:
`parent::__construct($p); $this->init($p);` is fan-out. Rule 4's "same object"
exemption spares `parent::` from the hop and class counts only — it does not extend
to fan-out, and nothing is lost by ending the chain there, because the base-class
chain is reported from the base method as its own origin.

Callee identity is syntactic: a `CalleeRef`'s kind, name, and receiver hint. Two
consequences are on the record. `self::a($p); Foo::a($p);` inside `Foo`, like
`$this->go($p); $c->go($p);` with `$c` typed as the own class, counts as two callees
even though both reach the same method (conservative — the chain ends there).
`$this->left->save($p); $this->right->save($p);` collapses into *one* callee, because
every property-fetch receiver hints `raw`: same-named methods on two collaborators
leave the method a hop, which is more findings than truth. That over-reporting is
bounded — the resolver cannot follow a `raw` receiver either, so it truncates at that
forward. Different method names on `raw` receivers do fan out.

The test is syntactic, so two shapes are decided by where a forward sits rather than
by what actually runs. Any early-return or guard-clause dispatcher counts as fan-out,
because no two of its forwards sit in different arms of the same branch:
`if ($x) { return a($p); } return b($p);`, the `instanceof` ladder
(`if ($x instanceof A) { return a($p); } if ($x instanceof B) { return b($p); }
throw …`), and `try { a($p); } catch (\Throwable) { b($p); }` alike. `switch`
fallthrough (`case 1: a($p); case 2: b($p);` with no `break`) goes the other way and
counts as exclusive arms. Both are deliberate simplifications of the shape test; the
early-return one is the conservative direction (the chain ends there, so fewer
findings), the fallthrough one leaves the pre-amendment classification in place — the
method keeps being a hop, so chains run on through it, which is more findings than
truth. `--exclude-terminal fan-out` targets the kind.
2. **By-ref receipt terminates.** A parameter declared `&$p` is `ByRefTerminated`: the
method may write to it, so it is never a mindless hop. Reported with an
`&-terminated` note.
Expand Down Expand Up @@ -109,7 +146,7 @@ implementation. Every bullet is a fixture.
(`function a(...$args) { b(...$args); }` is a hop).
6. **Interfaces/abstract types:** follow the call **only when exactly one implementation
exists in the analyzed code**; otherwise truncate with note
`"N implementations, chain truncated"`. Record the fan-out count (future
`"N implementations, chain truncated"`. Record the implementation count (future
`--follow-all-implementations`).
7. **Out of scope v0.1** (truncate with a note where detectable): closures / arrow
functions, first-class callable syntax, `call_user_func*`, `__call`/`__callStatic`,
Expand Down Expand Up @@ -687,8 +724,8 @@ Tasks (expanded in `docs/plans/phase-2.md`):
excludes lines by design).
4. **Terminals:** child fate `Used` → terminal kind `used` (or `stored` when the
body is a single property assignment); `ByRefTerminated` → `&-terminated`;
`Unused` → `unused-end` (dead forwarding — the value goes nowhere); leaf from
resolution → `external` or `truncated:<reason>`.
`Unused` → `unused-end` (dead forwarding — the value goes nowhere);
`FanOut` → `fan-out`; leaf from resolution → `external` or `truncated:<reason>`.
5. **Hops** = count of `PureForward` nodes on the path (terminal never counted).
`Finding{param, origin, terminal, terminalKind, hops, chain[], classes, notes}`;
`classes` = distinct declaring classes across chain incl. terminal.
Expand Down Expand Up @@ -835,7 +872,7 @@ Explicit maybe-laters recorded during Phases 0–6; each waits on a concrete con
ask unless the measurement already says "not yet".

- **`--follow-all-implementations`:** follow all N implementations of an interface
instead of the single-implementation default — fan-out counts are already recorded
instead of the single-implementation default — implementation counts are already recorded
since Phase 2 (see the single-implementation follow-through bullet in the Appendix),
so this is a small flag, not a rewrite. Origin: Phase 2 frozen-semantics decision.
- **Field/property tramping:** constructor-stored values re-forwarded via properties
Expand Down
6 changes: 4 additions & 2 deletions src/Cache/FileIndexCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@
final class FileIndexCache
{
/**
* Bump when {@see FileIndex} or any nested value object changes shape; a
* Bump when {@see FileIndex} or any nested value object changes shape, and
* whenever the classifier would give the same source a different answer —
* an entry is only ever allowed to be a miss, never a stale result. A
* cached entry written under an older format is silently ignored.
*/
private const FORMAT = 2;
private const FORMAT = 3;

/** Directory permission mode for lazily-created cache directories. */
private const DIRECTORY_MODE = 0o755;
Expand Down
15 changes: 6 additions & 9 deletions src/Chain/ChainTraversal.php
Original file line number Diff line number Diff line change
Expand Up @@ -226,15 +226,12 @@ private function terminalUse(MethodInfo $method, ParamInfo $param): Terminal

private function terminalKind(ParamInfo $param): TerminalKind
{
if ($param->fate === ParamFate::ByRefTerminated) {
return TerminalKind::ByRef;
}

if ($param->fate === ParamFate::Unused) {
return TerminalKind::Unused;
}

return $param->storedOnly ? TerminalKind::Stored : TerminalKind::Used;
return match ($param->fate) {
ParamFate::ByRefTerminated => TerminalKind::ByRef,
ParamFate::Unused => TerminalKind::Unused,
ParamFate::FanOut => TerminalKind::FanOut,
ParamFate::Used, ParamFate::PureForward => $param->storedOnly ? TerminalKind::Stored : TerminalKind::Used,
};
}

private function record(PartialChain $chain, Terminal $terminal): void
Expand Down
2 changes: 1 addition & 1 deletion src/Chain/Finding.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ final class Finding
{
/**
* @param list<Hop> $chain hops in order; includes the terminal node only when
* terminalKind is used|stored|&-terminated|unused-end
* `terminalKind->keepsTerminalNode()`
* @param list<string> $notes human-readable, e.g. "truncated: 2 implementations"
* @param list<string> $trace per-edge resolution trace, rendered only by --explain
*/
Expand Down
9 changes: 5 additions & 4 deletions src/Chain/TerminalKind.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@

/**
* How a tramp-data chain ends. The backing values are the tokens the reporters
* print. `used`/`stored`/`&-terminated`/`unused-end` keep the terminal node in
* the chain; `external`/`truncated` do not (the chain left analyzed code or was
* conservatively cut short).
* print. `used`/`stored`/`fan-out`/`&-terminated`/`unused-end` keep the terminal
* node in the chain; `external`/`truncated` do not (the chain left analyzed code
* or was conservatively cut short).
*/
enum TerminalKind: string
{
case Used = 'used';
case Stored = 'stored';
case FanOut = 'fan-out';
case ByRef = '&-terminated';
case Unused = 'unused-end';
case External = 'external';
Expand All @@ -29,7 +30,7 @@ enum TerminalKind: string
public function keepsTerminalNode(): bool
{
return match ($this) {
self::Used, self::Stored, self::ByRef, self::Unused => true,
self::Used, self::Stored, self::FanOut, self::ByRef, self::Unused => true,
self::External, self::Truncated => false,
};
}
Expand Down
2 changes: 1 addition & 1 deletion src/Console/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ private static function helpText(): string
--format <fmt> text|pretty|json|github|checkstyle|sarif|summary (default: pretty on TTY, text otherwise)
--color <mode> always|auto|never (default: auto; honors NO_COLOR in auto mode)
--explain Show why chains ended (call resolution trace)
--exclude-terminal <kind> Do not report chains ending in <kind> (repeatable; used|stored|&-terminated|unused-end|external|truncated)
--exclude-terminal <kind> Do not report chains ending in <kind> (repeatable; used|stored|fan-out|&-terminated|unused-end|external|truncated)

Diff-aware mode:
--changed-only Only report chains touching changed lines
Expand Down
118 changes: 118 additions & 0 deletions src/Index/BranchArmPath.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

declare(strict_types=1);

namespace PhpTramp\Index;

use PhpParser\Node;
use PhpParser\Node\Expr\Ternary;
use PhpParser\Node\MatchArm;
use PhpParser\Node\Stmt\Case_;
use PhpParser\Node\Stmt\Else_;
use PhpParser\Node\Stmt\ElseIf_;
use PhpParser\Node\Stmt\If_;

/**
* Which arm of every enclosing branching construct a syntax node sits in. Two
* nodes cannot both run on one execution path when some branch names them in
* different arms.
*/
final class BranchArmPath
{
/** @param array<int, int|string> $armByBranch arm label keyed by branching node object id */
private function __construct(private readonly array $armByBranch)
{
}

/**
* Requires the `parent` attribute to be set on the node and its ancestors.
*
* Only an arm's *body* counts as an arm. A branch condition — `if (…)`,
* `elseif (…)`, a `match` or `switch` subject, a match arm's or case's
* condition, a ternary condition — is evaluated on the way into the arms
* that follow it, so it can never be exclusive with any of them. Recording
* nothing for a condition lets it share the path with every arm, which is
* also the conservative direction: a shared path can only yield fewer
* findings.
*
* Object ids are safe as keys and labels here because the whole method
* body stays alive for as long as any path derived from it is compared, so
* no id can be recycled onto a different node in between.
*/
public static function of(Node $node): self
{
$armByBranch = [];
$child = $node;

while (($parent = $child->getAttribute('parent')) instanceof Node) {
$arm = self::armOf($parent, $child);
if ($arm !== null) {
[$branch, $label] = $arm;
$armByBranch[spl_object_id($branch)] = $label;
}
$child = $parent;
}

return new self($armByBranch);
}

public function isExclusiveWith(self $other): bool
{
foreach ($this->armByBranch as $branch => $arm) {
if (isset($other->armByBranch[$branch]) && $other->armByBranch[$branch] !== $arm) {
return true;
}
}

return false;
}

/**
* @return array{Node, int|string}|null the branching node the label belongs to
* and the label, or null when the child
* does not sit in an arm of the parent
*/
private static function armOf(Node $parent, Node $child): ?array
{
return match (true) {
$parent instanceof If_ => in_array($child, $parent->stmts, true) ? [$parent, 'then'] : null,
$parent instanceof Ternary => self::ternaryArm($parent, $child),
$parent instanceof ElseIf_,
$parent instanceof Else_,
$parent instanceof Case_ => self::armNode($parent, $parent->stmts, $child),
$parent instanceof MatchArm => self::armNode($parent, [$parent->body], $child),
default => null,
};
}

/** @return array{Node, int|string}|null */
private static function ternaryArm(Ternary $branch, Node $child): ?array
{
if ($child === $branch->if) {
return [$branch, 'then'];
}

return $child === $branch->else ? [$branch, 'else'] : null;
}

/**
* An arm that is a node of its own (`elseif`, `else`, a match arm, a case)
* hangs off the branching node as its parent and is labelled by its own
* identity: labels are only ever compared between arms of one branch, so
* any value that differs per arm will do, and the object id needs no scan
* of the arm's siblings.
*
* @param array<Node> $body
*
* @return array{Node, int|string}|null
*/
private static function armNode(Node $arm, array $body, Node $child): ?array
{
$branch = $arm->getAttribute('parent');
if (! $branch instanceof Node || ! in_array($child, $body, true)) {
return null;
}

return [$branch, spl_object_id($arm)];
}
}
7 changes: 7 additions & 0 deletions src/Index/CalleeRef.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,11 @@ public function __construct(
public readonly ?string $receiverHint = null,
) {
}

public function isSameAs(self $other): bool
{
return $this->kind === $other->kind
&& $this->name === $other->name
&& $this->receiverHint === $other->receiverHint;
}
}
47 changes: 47 additions & 0 deletions src/Index/FanOutDetector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace PhpTramp\Index;

/**
* Decides whether the forwards of one parameter fan out: reach two or more
* distinct callees on a single execution path. A method that does so combines
* what those callees return, so it consumes the parameter instead of passing
* it on. Forwards that only ever reach distinct callees in mutually exclusive
* branch arms are a dispatcher, not a fan-out.
*/
final class FanOutDetector
{
/** @var list<ForwardOccurrence> */
private array $forwards = [];

public function record(ForwardSite $site, BranchArmPath $branchArms): void
{
$this->forwards[] = new ForwardOccurrence($site, $branchArms);
}

/**
* Compares every ordered pair, self-pairs included: a forward is never
* distinct from itself, so those answer false on their own. There are only
* ever a handful of forwards per parameter.
*/
public function fansOut(): bool
{
foreach ($this->forwards as $forward) {
foreach ($this->forwards as $other) {
if (self::areDistinctOnOnePath($forward, $other)) {
return true;
}
}
}

return false;
}

private static function areDistinctOnOnePath(ForwardOccurrence $one, ForwardOccurrence $other): bool
{
return ! $one->site->callee->isSameAs($other->site->callee)
&& ! $one->branchArms->isExclusiveWith($other->branchArms);
}
}
14 changes: 13 additions & 1 deletion src/Index/ForwardCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@
* either a pure whole-argument forward or a real use. Subtrees that open a new
* scope (closures, arrow functions, nested classes/functions) are not
* descended into; instead the frozen closure/arrow-capture rules are applied
* at the boundary. Requires the `parent` attribute to be set on visited nodes.
* at the boundary. Alongside the forwards it detects whether they fan out to
* distinct callees on one execution path. Requires the `parent` attribute to be
* set on visited nodes.
*/
final class ForwardCollector extends NodeVisitorAbstract
{
Expand All @@ -44,10 +46,19 @@ final class ForwardCollector extends NodeVisitorAbstract

private bool $sawNonStore = false;

private readonly FanOutDetector $fanOutDetector;

public function __construct(
private readonly string $name,
private readonly bool $variadic,
) {
$this->fanOutDetector = new FanOutDetector();
}

/** True when the forwards reach two or more distinct callees on one path. */
public function fansOut(): bool
{
return $this->fanOutDetector->fansOut();
}

/** True when every occurrence of the parameter is a `$this->prop = $p` store. */
Expand Down Expand Up @@ -112,6 +123,7 @@ private function handleVariable(Variable $node): void

$this->forwards[] = $forward;
$this->sawNonStore = true;
$this->fanOutDetector->record($forward, BranchArmPath::of($node));
}

private function markUse(bool $isStore): void
Expand Down
Loading
Loading