diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e3fbbc..011a132 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,11 @@ jobs: run: composer stan - name: PHPMD (codesize) run: composer md + # Dogfood: run phptramp on its own source. Report-only for now (--limit 99 + # can never fail) so the chain output is visible in CI logs; Phase 6 lowers + # it to a real gate once the codebase is deliberately chain-clean. + - name: Dogfood (report-only) + run: bin/phptramp --folder src --limit 99 # Line coverage says a line ran; it does not say a test would notice if the # line were wrong. Infection changes the line and reports whether anything diff --git a/README.md b/README.md index 79ea9df..b6449ef 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,28 @@ > methods that never use them — "this parameter was passed through 4 classes / 5 methods > before being used." -**Status: Phase 1.** The parameter usage classifier and whole-project index are -implemented and inspectable via `phptramp --folder src --dump-index`; cross-file -chain reporting (the default output) lands in Phase 2. +**Status: Phase 2.** Cross-file chain reporting works: `phptramp --folder src` +stitches forwarding chains across files and prints text findings, exiting `0`/`1`/`2`. +The classifier and whole-project index remain inspectable via `--dump-index`; other +output formats (`json`, `github`, …) land in Phase 3. See [docs/plan.md](docs/plan.md) for the full implementation plan and current phase. -## What it will do +## What it does ```text $ vendor/bin/phptramp --folder src --limit 3 FINDING $config: 3 pass-through hops across 4 classes - origin App\Http\Controller::handle($config) src/Http/Controller.php:21 - hop 1 App\Service\ServiceA::process($config) src/Service/ServiceA.php:14 - hop 2 App\Service\ServiceB::run($config) src/Service/ServiceB.php:9 - terminal App\Mail\Mailer::__construct($config) src/Mail/Mailer.php:12 (stored) + origin App\Http\Controller::handle($config) src/Http/Controller.php:21 + hop 2 App\Service\ServiceA::process($config) src/Service/ServiceA.php:14 + hop 3 App\Service\ServiceB::run($config) src/Service/ServiceB.php:9 + terminal App\Mail\Mailer::__construct($config) src/Mail/Mailer.php:12 (stored) ``` +The origin is hop 1, so a 3-hop chain runs origin → hop 2 → hop 3 → terminal; the +summary counts the origin. Add `--explain` to see, per edge, how each call was +resolved. + A **hop** is a method that receives a parameter and *purely forwards* it — never reads a property, never calls a method on it, never uses it in an expression. Long hop chains are the "tramp data" smell: every method in the middle is coupled to a value it has no diff --git a/docs/plan.md b/docs/plan.md index 41980a8..1d0cd37 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -607,7 +607,7 @@ class Mailer --- -## Phase 2 — Chain stitching → *usable tool* (in progress — issue #3) +## Phase 2 — Chain stitching → *usable tool* ✅ (issue #3) **Deliverable:** `phptramp --folder src --limit 3` prints text findings, exits 0/1/2. diff --git a/docs/plans/phase-2.md b/docs/plans/phase-2.md index 244844a..557e70f 100644 --- a/docs/plans/phase-2.md +++ b/docs/plans/phase-2.md @@ -105,13 +105,13 @@ enum ClassKind `$name`). `IndexingVisitor::recordClass()` maps the node type; `Class_` checks `$node->isAbstract()`. -- [ ] **Step 1:** extend `IndexerTest` with one test: index a code string containing +- [x] **Step 1:** extend `IndexerTest` with one test: index a code string containing a concrete class, an abstract class, an interface, a trait, and an enum; assert `classInfo(...)->kind` for all five. -- [ ] **Step 2:** run → red (enum missing). -- [ ] **Step 3:** implement; fix the two `ClassInfo` construction sites. -- [ ] **Step 4:** green; `composer check` green. -- [ ] **Step 5:** commit `feat(#3): record class kind in the index`. +- [x] **Step 2:** run → red (enum missing). +- [x] **Step 3:** implement; fix the two `ClassInfo` construction sites. +- [x] **Step 4:** green; `composer check` green. +- [x] **Step 5:** commit `feat(#3): record class kind in the index`. ### Task 2: `ClassHierarchy` @@ -143,14 +143,14 @@ final class ClassHierarchy Cycle guard: parent/interface walks carry a visited set — malformed input code must not hang the analyzer. -- [ ] **Step 1:** failing tests, built on the temp-file Indexer helper: +- [x] **Step 1:** failing tests, built on the temp-file Indexer helper: method found directly; via trait `use`; via parent; via grandparent; trait beats parent (PHP semantics); unknown class → null. `implementationsOf`: interface with one concrete impl; with two (sorted); via `extends` chain of interfaces; via abstract base; abstract class itself never listed; unknown name → `[]`. `isAbstractType` for all five kinds. -- [ ] **Step 2:** red. **Step 3:** implement. **Step 4:** green + check. -- [ ] **Step 5:** commit `feat(#3): class hierarchy with trait flattening and implementation lookup`. +- [x] **Step 2:** red. **Step 3:** implement. **Step 4:** green + check. +- [x] **Step 5:** commit `feat(#3): class hierarchy with trait flattening and implementation lookup`. ### Task 3: `Resolution` + `ReceiverTyper` + `CallResolver` @@ -222,7 +222,7 @@ bindings to Phase 1 reality: position, where positions ≥ the variadic collector's position bind to the variadic param. No match → `TruncatedResolution('argument does not bind to a parameter')`. -- [ ] **Step 1:** failing tests (temp-file Indexer helper; one test per rule): +- [x] **Step 1:** failing tests (temp-file Indexer helper; one test per rule): function in index; `sprintf` → External; static via class name; static inherited from parent; `$this->m()`; method via param declared type; via inline-new receiver (Phase 1 emits the inferred FQCN hint); interface single impl → that impl; @@ -231,9 +231,9 @@ bindings to Phase 1 reality: `new X($p)` with constructor; `new X($p)` without constructor → External; named-arg binding (`argKey 'cfg'` → param `cfg`); positional past variadic binds to variadic; positional arg beyond the last param → Truncated. -- [ ] **Step 2:** red. **Step 3:** implement (`CallResolver` dispatches per kind; +- [x] **Step 2:** red. **Step 3:** implement (`CallResolver` dispatches per kind; `ReceiverTyper` owns hint→FQCN; both stay PHPMD-clean — extract, don't nest). -- [ ] **Step 4:** green + check. **Step 5:** commit `feat(#3): call resolver`. +- [x] **Step 4:** green + check. **Step 5:** commit `feat(#3): call resolver`. ### Task 4: `storedOnly` on `ParamInfo` @@ -245,11 +245,11 @@ classifier fact: fate is `Used` *and* every use is a property assignment `public readonly bool $storedOnly = false` as last constructor arg), `src/Index/UsageClassifier.php`; test `tests/Index/UsageClassifierTest.php`. -- [ ] **Step 1:** failing tests: `$this->x = $p;` → `Used` + `storedOnly` true; +- [x] **Step 1:** failing tests: `$this->x = $p;` → `Used` + `storedOnly` true; promoted `private Cfg $p` → true; `$this->x = $p; log($p->env);` → false; `other($p);` (PureForward) → false; plain `log($p);` → false. -- [ ] **Step 2:** red. **Step 3:** implement. **Step 4:** green + check. -- [ ] **Step 5:** commit `feat(#3): classify stores-only parameter usage`. +- [x] **Step 2:** red. **Step 3:** implement. **Step 4:** green + check. +- [x] **Step 5:** commit `feat(#3): classify stores-only parameter usage`. ### Task 5: `Chain/` value objects + `ChainBuilder` @@ -330,7 +330,7 @@ Bindings and determinism decisions: `": : -> "` (or `-> external: ` / `-> truncated: `). -- [ ] **Step 1:** failing tests (temp-file helper; assert against `Finding` fields): +- [x] **Step 1:** failing tests (temp-file helper; assert against `Finding` fields): the 3-hop README chain (origin/terminal/hops=3/classes=4/`Stored`); use at hop 2 splits the chain (finding of 1 hop ending `Used`); branch to two callees → two findings sharing the origin; by-ref callee → `ByRef`; callee never touching the @@ -339,10 +339,10 @@ Bindings and determinism decisions: `(recursive)` note, terminates; entry-less cycle (a↔b, no other caller) → still reported once with `(recursive)`; hops ≥ limit is NOT applied here (a 1-hop chain is returned). -- [ ] **Step 2:** red. **Step 3:** implement (DFS with the current path as visited +- [x] **Step 2:** red. **Step 3:** implement (DFS with the current path as visited set; keep it PHPMD-clean — the walk, terminal mapping, and finding assembly are separate methods or a small helper class). -- [ ] **Step 4:** green + check. **Step 5:** commit `feat(#3): chain builder`. +- [x] **Step 4:** green + check. **Step 5:** commit `feat(#3): chain builder`. ### Task 6: `TextReporter`, default pipeline, exit codes @@ -375,12 +375,12 @@ FileLocator → Indexer → ClassHierarchy/CallResolver → ChainBuilder, filter Phase 1 "not implemented" branch dies. Formats other than `text` return exit 2 with `format not implemented until Phase 3`. -- [ ] **Step 1:** failing reporter test (exact-string, both finding shapes and the +- [x] **Step 1:** failing reporter test (exact-string, both finding shapes and the empty case) + Application tests: 3-hop fixture code → exit 1 and output contains `FINDING`; same code with `--limit 4` → exit 0 and `No tramp data found`; `--format json` → exit 2. -- [ ] **Step 2:** red. **Step 3:** implement. **Step 4:** green + check. -- [ ] **Step 5:** commit `feat(#3): text reporter, default pipeline, exit codes`. +- [x] **Step 2:** red. **Step 3:** implement. **Step 4:** green + check. +- [x] **Step 5:** commit `feat(#3): text reporter, default pipeline, exit codes`. ### Task 7: `--explain` @@ -390,7 +390,7 @@ tests extend `tests/Report/TextReporterTest.php`. With `Options->explain`, each finding is followed by its `trace` lines indented under an `explain:` header; without the flag, output is unchanged (pin both). -- [ ] Steps: failing test → red → implement → green + check → +- [x] Steps: failing test → red → implement → green + check → commit `feat(#3): --explain resolution traces`. ### Task 8: End-to-end fixtures, harness extension, dogfood @@ -433,7 +433,7 @@ the codebase is chain-clean, then Phase 6 lowers it to a real gate. Update `README.md` status ("Phase 2: chain reporting works") and check off Phase 2 in `docs/plan.md`. -- [ ] Steps: harness extension test-first (red on the first new fixture) → fixtures +- [x] Steps: harness extension test-first (red on the first new fixture) → fixtures one by one → green + check → commit `test(#3): end-to-end chain fixtures` → CI/docs edits → commit `ci(#3): dogfood phptramp on itself (report-only)`. diff --git a/src/Chain/ChainBuilder.php b/src/Chain/ChainBuilder.php new file mode 100644 index 0000000..8d25c7d --- /dev/null +++ b/src/Chain/ChainBuilder.php @@ -0,0 +1,29 @@ + + */ + public function build(MethodIndex $index): array + { + return (new ChainTraversal($index, $this->resolver))->run(); + } +} diff --git a/src/Chain/ChainTraversal.php b/src/Chain/ChainTraversal.php new file mode 100644 index 0000000..934d60c --- /dev/null +++ b/src/Chain/ChainTraversal.php @@ -0,0 +1,235 @@ + */ + private array $findings = []; + + /** @var array */ + private array $visited = []; + + public function __construct( + private readonly MethodIndex $index, + private readonly CallResolver $resolver, + ) { + } + + /** + * @return list + */ + public function run(): array + { + $nodes = $this->pureForwardNodes(); + $incoming = $this->incomingTargets($nodes); + + foreach ($nodes as [$method, $param]) { + if (! isset($incoming[$this->key($method->fqmn, $param->name)])) { + $this->walk($method, $param, new PartialChain($param->name)); + } + } + + foreach ($nodes as [$method, $param]) { + if (! isset($this->visited[$this->key($method->fqmn, $param->name)])) { + $this->walk($method, $param, new PartialChain($param->name)); + } + } + + $this->sortFindings(); + + return $this->findings; + } + + /** + * @return list + */ + private function pureForwardNodes(): array + { + $nodes = []; + foreach ($this->index->all() as $method) { + foreach ($method->params as $param) { + if ($param->fate === ParamFate::PureForward) { + $nodes[] = [$method, $param]; + } + } + } + + return $nodes; + } + + /** + * @param list $nodes + * @return array + */ + private function incomingTargets(array $nodes): array + { + $incoming = []; + foreach ($nodes as [$method, $param]) { + foreach ($param->forwards as $forward) { + $resolution = $this->resolver->resolve($forward, $method); + if ($resolution instanceof ResolvedTarget) { + $incoming[$this->key($resolution->fqmn, $resolution->boundParam)] = true; + } + } + } + + return $incoming; + } + + private function walk(MethodInfo $method, ParamInfo $param, PartialChain $chain): void + { + $key = $this->key($method->fqmn, $param->name); + $this->visited[$key] = true; + + foreach ($param->forwards as $forward) { + $resolution = $this->resolver->resolve($forward, $method); + $hop = new Hop($method->fqmn, $method->class, $method->file, $method->line, $forward->line); + $advanced = $chain->append($hop, $this->traceLine($method->fqmn, $forward, $resolution), $key); + $this->follow($resolution, $advanced); + } + } + + private function follow(Resolution $resolution, PartialChain $chain): void + { + if ($resolution instanceof ResolvedTarget) { + $this->followTarget($resolution, $chain); + + return; + } + + if ($resolution instanceof ExternalTarget) { + $this->record($chain, new Terminal(null, null, TerminalKind::External)); + + return; + } + + if ($resolution instanceof TruncatedResolution) { + $note = 'truncated: ' . $resolution->reason; + $this->record($chain, new Terminal(null, null, TerminalKind::Truncated, [$note])); + } + } + + private function followTarget(ResolvedTarget $target, PartialChain $chain): void + { + $method = $this->index->get($target->fqmn); + $param = $method?->paramNamed($target->boundParam); + if ($method === null || $param === null) { + $this->record($chain, new Terminal(null, null, TerminalKind::External)); + + return; + } + + if ($param->fate !== ParamFate::PureForward) { + $this->record($chain, $this->terminalUse($method, $param)); + + return; + } + + if ($chain->hasKey($this->key($target->fqmn, $target->boundParam))) { + $this->record($chain, new Terminal(null, null, TerminalKind::Truncated, ['(recursive)'])); + + return; + } + + $this->walk($method, $param, $chain); + } + + private function terminalUse(MethodInfo $method, ParamInfo $param): Terminal + { + $hop = new Hop($method->fqmn, $method->class, $method->file, $method->line, null); + + return new Terminal($hop, $method->fqmn, $this->terminalKind($param)); + } + + 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; + } + + private function record(PartialChain $chain, Terminal $terminal): void + { + $fullChain = $terminal->hop === null ? $chain->hops : [...$chain->hops, $terminal->hop]; + + $this->findings[] = new Finding( + $chain->originParam, + $chain->hops[0]->fqmn, + $terminal->fqmn, + $terminal->kind, + count($chain->hops), + $fullChain, + $this->distinctClasses($fullChain), + $terminal->notes, + $chain->trace, + ); + } + + /** + * @param list $chain + */ + private function distinctClasses(array $chain): int + { + $classes = []; + foreach ($chain as $hop) { + if ($hop->class !== null) { + $classes[$hop->class] = true; + } + } + + return count($classes); + } + + private function traceLine(string $callerFqmn, ForwardSite $forward, Resolution $resolution): string + { + $callee = $forward->callee; + $edge = $callee->kind->value . ':' . $callee->name; + + return $callerFqmn . ': ' . $edge . ' -> ' . $resolution->describe(); + } + + private function sortFindings(): void + { + usort($this->findings, static function (Finding $left, Finding $right): int { + return [$left->chain[0]->file, $left->chain[0]->line, $left->param] + <=> [$right->chain[0]->file, $right->chain[0]->line, $right->param]; + }); + } + + private function key(string $fqmn, string $param): string + { + return $fqmn . self::KEY_SEPARATOR . $param; + } +} diff --git a/src/Chain/Finding.php b/src/Chain/Finding.php new file mode 100644 index 0000000..a72d154 --- /dev/null +++ b/src/Chain/Finding.php @@ -0,0 +1,32 @@ + $chain hops in order; includes the terminal node only when + * terminalKind is used|stored|&-terminated|unused-end + * @param list $notes human-readable, e.g. "truncated: 2 implementations" + * @param list $trace per-edge resolution trace, rendered only by --explain + */ + public function __construct( + public readonly string $param, + public readonly string $origin, + public readonly ?string $terminal, + public readonly TerminalKind $terminalKind, + public readonly int $hops, + public readonly array $chain, + public readonly int $classes, + public readonly array $notes, + public readonly array $trace, + ) { + } +} diff --git a/src/Chain/Hop.php b/src/Chain/Hop.php new file mode 100644 index 0000000..1e7f6b5 --- /dev/null +++ b/src/Chain/Hop.php @@ -0,0 +1,23 @@ + $hops + * @param list $trace + * @param list $keys + */ + public function __construct( + public readonly string $originParam, + public readonly array $hops = [], + public readonly array $trace = [], + public readonly array $keys = [], + ) { + } + + public function append(Hop $hop, string $traceLine, string $key): self + { + return new self( + $this->originParam, + [...$this->hops, $hop], + [...$this->trace, $traceLine], + [...$this->keys, $key], + ); + } + + public function hasKey(string $key): bool + { + return in_array($key, $this->keys, true); + } +} diff --git a/src/Chain/Terminal.php b/src/Chain/Terminal.php new file mode 100644 index 0000000..5f600ed --- /dev/null +++ b/src/Chain/Terminal.php @@ -0,0 +1,24 @@ + $notes + */ + public function __construct( + public readonly ?Hop $hop, + public readonly ?string $fqmn, + public readonly TerminalKind $kind, + public readonly array $notes = [], + ) { + } +} diff --git a/src/Chain/TerminalKind.php b/src/Chain/TerminalKind.php new file mode 100644 index 0000000..a3b7603 --- /dev/null +++ b/src/Chain/TerminalKind.php @@ -0,0 +1,21 @@ +dumpIndex($options); } - fwrite($this->stderr, "phptramp: chain analysis is not implemented yet (Phase 1). See docs/plan.md.\n"); + return $this->analyze($options); + } + + private function analyze(Options $options): int + { + if ($options->format !== 'text') { + fwrite($this->stderr, "phptramp: format '{$options->format}' is not implemented until Phase 3.\n"); + + return 2; + } + + try { + $index = $this->buildIndex($options); + } catch (InvalidArgsException | ParseException $e) { + fwrite($this->stderr, 'phptramp: ' . $e->getMessage() . "\n"); + + return 2; + } + + $findings = $this->findChains($index, $options->limit); + fwrite($this->stdout, (new TextReporter($options->limit, $options->explain))->render($findings)); + + return $findings === [] ? 0 : 1; + } + + private function buildIndex(Options $options): MethodIndex + { + $files = (new FileLocator())->locate($options); + + return (new Indexer())->index($files); + } + + /** + * @return list + */ + private function findChains(MethodIndex $index, int $limit): array + { + $resolver = new CallResolver($index, new ClassHierarchy($index)); + $findings = (new ChainBuilder($resolver))->build($index); - return 2; + return array_values(array_filter($findings, static fn (Finding $finding): bool => $finding->hops >= $limit)); } private function dumpIndex(Options $options): int @@ -158,8 +201,8 @@ private static function helpText(): string 1 at least one finding at or over --limit 2 tool error (bad arguments, parse failure, ...) - Status: Phase 1 - classifier and --dump-index shipped; chain analysis - (default reporting) lands in Phase 2. See docs/plan.md. + Status: Phase 2 - cross-file chain reporting works in the text format; + other formats land in Phase 3. See docs/plan.md. TXT; } diff --git a/src/Index/ClassInfo.php b/src/Index/ClassInfo.php index 545909f..f395054 100644 --- a/src/Index/ClassInfo.php +++ b/src/Index/ClassInfo.php @@ -17,6 +17,7 @@ final class ClassInfo */ public function __construct( public readonly string $name, + public readonly ClassKind $kind, public readonly ?string $parent, public readonly array $interfaces, public readonly array $traits, diff --git a/src/Index/ClassKind.php b/src/Index/ClassKind.php new file mode 100644 index 0000000..9b5d337 --- /dev/null +++ b/src/Index/ClassKind.php @@ -0,0 +1,20 @@ + */ public array $forwards = []; + private bool $sawStore = false; + + private bool $sawNonStore = false; + public function __construct( private readonly string $name, private readonly bool $variadic, ) { } + /** True when every occurrence of the parameter is a `$this->prop = $p` store. */ + public function storesOnly(): bool + { + return $this->sawStore && ! $this->sawNonStore; + } + public function enterNode(Node $node): ?int { if ($node instanceof Closure) { @@ -73,7 +85,7 @@ private function handleClosure(Closure $node): void { foreach ($node->uses as $use) { if ($use->var->name === $this->name) { - $this->used = true; + $this->markUse(false); } } } @@ -85,7 +97,7 @@ private function handleArrowFunction(ArrowFunction $node): void fn (Node $n): bool => $n instanceof Variable && $n->name === $this->name, ); if ($hit !== null) { - $this->used = true; + $this->markUse(false); } } @@ -93,12 +105,39 @@ private function handleVariable(Variable $node): void { $forward = $this->asForward($node); if ($forward === null) { - $this->used = true; + $this->markUse($this->isThisPropertyStore($node)); return; } $this->forwards[] = $forward; + $this->sawNonStore = true; + } + + private function markUse(bool $isStore): void + { + $this->used = true; + if ($isStore) { + $this->sawStore = true; + + return; + } + + $this->sawNonStore = true; + } + + private function isThisPropertyStore(Variable $var): bool + { + $assign = $var->getAttribute('parent'); + if (! $assign instanceof Assign || $assign->expr !== $var) { + return false; + } + + $target = $assign->var; + + return $target instanceof PropertyFetch + && $target->var instanceof Variable + && $target->var->name === 'this'; } private function asForward(Variable $var): ?ForwardSite diff --git a/src/Index/IndexingVisitor.php b/src/Index/IndexingVisitor.php index 93dd824..98b8e1a 100644 --- a/src/Index/IndexingVisitor.php +++ b/src/Index/IndexingVisitor.php @@ -13,6 +13,7 @@ use PhpParser\Node\Stmt\Enum_; use PhpParser\Node\Stmt\Function_; use PhpParser\Node\Stmt\Interface_; +use PhpParser\Node\Stmt\Trait_; use PhpParser\Node\Stmt\TraitUse; use PhpParser\NodeVisitorAbstract; @@ -25,7 +26,7 @@ * Relies on NameResolver (FQ names) and ParentConnectingVisitor (the `parent` * attribute) running before it in the traverser. * - * @phpstan-type ClassFacts array{parent: ?string, interfaces: list, traits: list} + * @phpstan-type ClassFacts array{kind: ClassKind, parent: ?string, interfaces: list, traits: list} * @phpstan-type PendingMethod array{fqmn: string, file: string, line: int, class: ?string, node: FunctionLike} */ final class IndexingVisitor extends NodeVisitorAbstract @@ -75,6 +76,7 @@ public function classes(): array foreach ($this->classes as $fqcn => $facts) { $out[$fqcn] = new ClassInfo( $fqcn, + $facts['kind'], $facts['parent'], $facts['interfaces'], $facts['traits'], @@ -104,12 +106,24 @@ private function recordClass(ClassLike $node): void } $this->classes[$fqcn] = [ + 'kind' => $this->kindOf($node), 'parent' => $parent, 'interfaces' => $interfaces, 'traits' => [], ]; } + private function kindOf(ClassLike $node): ClassKind + { + return match (true) { + $node instanceof Interface_ => ClassKind::Interface_, + $node instanceof Trait_ => ClassKind::Trait_, + $node instanceof Enum_ => ClassKind::Enum_, + $node instanceof Class_ && $node->isAbstract() => ClassKind::AbstractClass, + default => ClassKind::ConcreteClass, + }; + } + private function recordTraitUse(TraitUse $node): void { $fqcn = $this->enclosingClassName($node); diff --git a/src/Index/MethodIndex.php b/src/Index/MethodIndex.php index 26f8a28..49086ab 100644 --- a/src/Index/MethodIndex.php +++ b/src/Index/MethodIndex.php @@ -37,4 +37,12 @@ public function classInfo(string $fqcn): ?ClassInfo { return $this->classes[$fqcn] ?? null; } + + /** + * @return iterable + */ + public function allClasses(): iterable + { + return $this->classes; + } } diff --git a/src/Index/MethodInfo.php b/src/Index/MethodInfo.php index f171711..04b30d3 100644 --- a/src/Index/MethodInfo.php +++ b/src/Index/MethodInfo.php @@ -21,4 +21,15 @@ public function __construct( public readonly ?string $class = null, ) { } + + public function paramNamed(string $name): ?ParamInfo + { + foreach ($this->params as $param) { + if ($param->name === $name) { + return $param; + } + } + + return null; + } } diff --git a/src/Index/ParamInfo.php b/src/Index/ParamInfo.php index 667e3c3..fdb6bd5 100644 --- a/src/Index/ParamInfo.php +++ b/src/Index/ParamInfo.php @@ -20,6 +20,8 @@ public function __construct( public readonly bool $byRef, public readonly bool $variadic, public readonly ?string $type, + /** Fate is Used and every use is a `$this->prop = $p` store (or promoted). */ + public readonly bool $storedOnly = false, ) { } } diff --git a/src/Index/UsageClassifier.php b/src/Index/UsageClassifier.php index c34567b..e7d1b06 100644 --- a/src/Index/UsageClassifier.php +++ b/src/Index/UsageClassifier.php @@ -52,24 +52,26 @@ private function classifyParam(Param $param, int $position, ?array $stmts): Para $name = ($param->var instanceof Variable && is_string($param->var->name)) ? $param->var->name : ''; $type = $this->typeToString($param->type); $forwards = []; + $storedOnly = false; if ($param->byRef) { $fate = ParamFate::ByRefTerminated; } elseif ($param->isPromoted()) { $fate = ParamFate::Used; + $storedOnly = true; } elseif ($stmts === null || $name === '') { $fate = ParamFate::Unused; } else { - [$fate, $forwards] = $this->analyzeBody($name, $param->variadic, $stmts); + [$fate, $forwards, $storedOnly] = $this->analyzeBody($name, $param->variadic, $stmts); } - return new ParamInfo($name, $position, $fate, $forwards, $param->byRef, $param->variadic, $type); + return new ParamInfo($name, $position, $fate, $forwards, $param->byRef, $param->variadic, $type, $storedOnly); } /** * @param array $stmts * - * @return array{0: ParamFate, 1: list} + * @return array{0: ParamFate, 1: list, 2: bool} */ private function analyzeBody(string $name, bool $variadic, array $stmts): array { @@ -79,13 +81,13 @@ private function analyzeBody(string $name, bool $variadic, array $stmts): array $traverser->traverse($stmts); if ($collector->used) { - return [ParamFate::Used, $collector->forwards]; + return [ParamFate::Used, $collector->forwards, $collector->storesOnly()]; } if ($collector->forwards === []) { - return [ParamFate::Unused, []]; + return [ParamFate::Unused, [], false]; } - return [ParamFate::PureForward, $collector->forwards]; + return [ParamFate::PureForward, $collector->forwards, false]; } private function typeToString(?Node $type): ?string diff --git a/src/Report/TextReporter.php b/src/Report/TextReporter.php new file mode 100644 index 0000000..d00611b --- /dev/null +++ b/src/Report/TextReporter.php @@ -0,0 +1,189 @@ + $findings already filtered to hops >= limit + */ + public function render(array $findings): string + { + if ($findings === []) { + return 'No tramp data found (' . $this->limitClause() . ").\n"; + } + + $blocks = array_map($this->renderFinding(...), $findings); + + return implode("\n\n", $blocks) . "\n\n" . $this->summary(count($findings)) . "\n"; + } + + private function renderFinding(Finding $finding): string + { + $hopRows = $this->hopRows($finding); + $labelWidth = $this->labelWidth($hopRows, $finding->notes); + $methodWidth = $this->methodWidth($hopRows); + + $lines = [$this->header($finding)]; + foreach ($hopRows as $row) { + $lines[] = $this->hopLine($row, $labelWidth, $methodWidth); + } + foreach ($finding->notes as $note) { + $lines[] = $this->labelledLine('note', $labelWidth, $note); + } + foreach ($this->explainLines($finding) as $explainLine) { + $lines[] = $explainLine; + } + + return implode("\n", $lines); + } + + /** + * @return list + */ + private function explainLines(Finding $finding): array + { + if (! $this->explain || $finding->trace === []) { + return []; + } + + $lines = [self::INDENT . 'explain:']; + foreach ($finding->trace as $traceLine) { + $lines[] = self::INDENT . self::INDENT . $traceLine; + } + + return $lines; + } + + /** + * @return list + */ + private function hopRows(Finding $finding): array + { + $hasTerminalNode = count($finding->chain) > $finding->hops; + + $rows = []; + foreach ($finding->chain as $index => $hop) { + $isTerminal = $hasTerminalNode && $index === $finding->hops; + $rows[] = [ + 'label' => $this->label($index, $isTerminal), + 'method' => $hop->fqmn . '($' . $finding->param . ')', + 'location' => $this->location($hop), + 'annotation' => $isTerminal ? '(' . $finding->terminalKind->value . ')' : '', + ]; + } + + return $rows; + } + + private function label(int $index, bool $isTerminal): string + { + if ($isTerminal) { + return 'terminal'; + } + if ($index === 0) { + return 'origin'; + } + + return 'hop ' . ($index + 1); + } + + private function location(Hop $hop): string + { + return $hop->file . ':' . $hop->line; + } + + /** + * @param array{label: string, method: string, location: string, annotation: string} $row + */ + private function hopLine(array $row, int $labelWidth, int $methodWidth): string + { + $rest = str_pad($row['method'], $methodWidth) . self::COLUMN_GAP . $row['location']; + if ($row['annotation'] !== '') { + $rest .= self::COLUMN_GAP . $row['annotation']; + } + + return $this->labelledLine($row['label'], $labelWidth, $rest); + } + + private function labelledLine(string $label, int $labelWidth, string $rest): string + { + return self::INDENT . str_pad($label, $labelWidth) . self::COLUMN_GAP . $rest; + } + + /** + * @param list $rows + * @param list $notes + */ + private function labelWidth(array $rows, array $notes): int + { + $widths = [self::MIN_LABEL_WIDTH]; + foreach ($rows as $row) { + $widths[] = strlen($row['label']); + } + if ($notes !== []) { + $widths[] = strlen('note'); + } + + return max($widths); + } + + /** + * @param list $rows + */ + private function methodWidth(array $rows): int + { + $widths = [0]; + foreach ($rows as $row) { + $widths[] = strlen($row['method']); + } + + return max($widths); + } + + private function header(Finding $finding): string + { + return 'FINDING $' . $finding->param . ': ' + . $finding->hops . ' pass-through ' . $this->plural($finding->hops, 'hop') + . ' across ' . $finding->classes . ' ' . $this->plural($finding->classes, 'class', 'classes'); + } + + private function summary(int $count): string + { + return $count . ' ' . $this->plural($count, 'finding') . ' (' . $this->limitClause() . ').'; + } + + private function limitClause(): string + { + return 'limit: ' . $this->limit . ' ' . $this->plural($this->limit, 'hop'); + } + + private function plural(int $count, string $singular, ?string $plural = null): string + { + if ($count === 1) { + return $singular; + } + + return $plural ?? $singular . 's'; + } +} diff --git a/src/Resolve/CallResolver.php b/src/Resolve/CallResolver.php new file mode 100644 index 0000000..c9aaa61 --- /dev/null +++ b/src/Resolve/CallResolver.php @@ -0,0 +1,216 @@ +receiverTyper = new ReceiverTyper(); + } + + public function resolve(ForwardSite $site, MethodInfo $caller): Resolution + { + return match ($site->callee->kind) { + CalleeKind::Func => $this->resolveFunction($site, $caller), + CalleeKind::StaticCall => $this->resolveStatic($site, $caller), + CalleeKind::Method => $this->resolveMethod($site, $caller), + CalleeKind::Instantiation => $this->resolveNew($site), + }; + } + + private function resolveFunction(ForwardSite $site, MethodInfo $caller): Resolution + { + $target = $this->functionTarget($site->callee->name, $caller); + if ($target === null) { + return new ExternalTarget('function not in index'); + } + + return $this->bind($target, $site); + } + + private function functionTarget(string $name, MethodInfo $caller): ?MethodInfo + { + foreach ($this->functionCandidates($name, $caller) as $candidate) { + $target = $this->index->get($candidate); + if ($target !== null) { + return $target; + } + } + + return null; + } + + /** + * An unqualified call in a namespace resolves to the namespaced function if + * one exists, else falls back to the global one (PHP's function fallback). + * + * @return list + */ + private function functionCandidates(string $name, MethodInfo $caller): array + { + if (str_contains($name, '\\')) { + return [$name]; + } + + $namespace = $this->namespaceOf($caller); + if ($namespace === '') { + return [$name]; + } + + return [$namespace . '\\' . $name, $name]; + } + + private function namespaceOf(MethodInfo $caller): string + { + $symbol = $caller->class ?? $caller->fqmn; + $separator = strrpos($symbol, '\\'); + + return $separator === false ? '' : substr($symbol, 0, $separator); + } + + private function resolveStatic(ForwardSite $site, MethodInfo $caller): Resolution + { + $class = $this->staticClass($site->callee->receiverHint, $caller); + if ($class === null || $this->index->classInfo($class) === null) { + return new ExternalTarget('class not in index'); + } + + return $this->resolveOnConcreteClass($class, $site); + } + + private function staticClass(?string $hint, MethodInfo $caller): ?string + { + return match ($hint) { + 'self', 'static' => $caller->class, + 'parent' => $this->parentOf($caller), + default => $hint, + }; + } + + private function parentOf(MethodInfo $caller): ?string + { + if ($caller->class === null) { + return null; + } + + return $this->index->classInfo($caller->class)?->parent; + } + + private function resolveMethod(ForwardSite $site, MethodInfo $caller): Resolution + { + $hint = $site->callee->receiverHint; + $type = $this->receiverTyper->type($hint, $caller); + if ($type === null) { + return new TruncatedResolution($this->untypeableReason($hint)); + } + + if ($this->index->classInfo($type) === null) { + return new ExternalTarget('receiver type not in index'); + } + + if ($this->hierarchy->isAbstractType($type)) { + return $this->resolveAbstract($type, $site); + } + + return $this->resolveOnConcreteClass($type, $site); + } + + private function untypeableReason(?string $hint): string + { + return ($hint === null || $hint === 'raw') ? 'untyped receiver' : 'unresolvable type'; + } + + private function resolveAbstract(string $type, ForwardSite $site): Resolution + { + $implementations = $this->hierarchy->implementationsOf($type); + $count = count($implementations); + + if ($count === 0) { + return new ExternalTarget('no implementation in index'); + } + + if ($count > 1) { + return new TruncatedResolution($count . ' implementations'); + } + + return $this->resolveOnConcreteClass($implementations[0], $site); + } + + private function resolveOnConcreteClass(string $class, ForwardSite $site): Resolution + { + $target = $this->hierarchy->methodOn($class, $site->callee->name); + if ($target === null) { + return new TruncatedResolution('method not found'); + } + + return $this->bind($target, $site); + } + + private function resolveNew(ForwardSite $site): Resolution + { + $class = $site->callee->name; + if ($this->index->classInfo($class) === null) { + return new ExternalTarget('class not in index'); + } + + $constructor = $this->hierarchy->methodOn($class, '__construct'); + if ($constructor === null) { + return new ExternalTarget('no constructor in index'); + } + + return $this->bind($constructor, $site); + } + + private function bind(MethodInfo $target, ForwardSite $site): Resolution + { + $param = $this->boundParam($target, $site->argKey); + if ($param === null) { + return new TruncatedResolution('argument does not bind to a parameter'); + } + + return new ResolvedTarget($target->fqmn, $param->name); + } + + private function boundParam(MethodInfo $target, int|string $argKey): ?ParamInfo + { + if (is_string($argKey)) { + return $target->paramNamed($argKey); + } + + return $this->paramByPosition($target, $argKey); + } + + private function paramByPosition(MethodInfo $target, int $position): ?ParamInfo + { + foreach ($target->params as $param) { + if ($param->position === $position) { + return $param; + } + + if ($param->variadic && $position >= $param->position) { + return $param; + } + } + + return null; + } +} diff --git a/src/Resolve/ClassHierarchy.php b/src/Resolve/ClassHierarchy.php new file mode 100644 index 0000000..384ba4d --- /dev/null +++ b/src/Resolve/ClassHierarchy.php @@ -0,0 +1,156 @@ +lookupMethod($fqcn, $method, []); + } + + /** True for Interface_ and AbstractClass kinds. */ + public function isAbstractType(string $fqcn): bool + { + $kind = $this->index->classInfo($fqcn)?->kind; + + return $kind === ClassKind::Interface_ || $kind === ClassKind::AbstractClass; + } + + /** + * Concrete classes/enums that implement the interface (transitively, incl. + * via parents) or extend the abstract class (transitively). Sorted for + * determinism. + * + * @return list + */ + public function implementationsOf(string $fqcn): array + { + if ($this->index->classInfo($fqcn) === null) { + return []; + } + + $implementations = []; + foreach ($this->concreteClasses() as $candidate) { + if (in_array($fqcn, $this->supertypesOf($candidate->name, []), true)) { + $implementations[] = $candidate->name; + } + } + + sort($implementations); + + return $implementations; + } + + /** + * @param list $visited + */ + private function lookupMethod(string $fqcn, string $method, array $visited): ?MethodInfo + { + if (in_array($fqcn, $visited, true)) { + return null; + } + + $class = $this->index->classInfo($fqcn); + if ($class === null) { + return null; + } + + $seen = [...$visited, $fqcn]; + + return $this->index->get($fqcn . '::' . $method) + ?? $this->methodInTraits($class->traits, $method, $seen) + ?? $this->methodOnParent($class->parent, $method, $seen); + } + + /** + * @param list $traits + * @param list $visited + */ + private function methodInTraits(array $traits, string $method, array $visited): ?MethodInfo + { + foreach ($traits as $trait) { + $found = $this->lookupMethod($trait, $method, $visited); + if ($found !== null) { + return $found; + } + } + + return null; + } + + /** + * @param list $visited + */ + private function methodOnParent(?string $parent, string $method, array $visited): ?MethodInfo + { + if ($parent === null) { + return null; + } + + return $this->lookupMethod($parent, $method, $visited); + } + + /** + * Transitive parents and interfaces (interfaces record their `extends` in + * the interface list), never the type itself. + * + * @param list $visited + * @return list + */ + private function supertypesOf(string $fqcn, array $visited): array + { + if (in_array($fqcn, $visited, true)) { + return []; + } + + $class = $this->index->classInfo($fqcn); + if ($class === null) { + return []; + } + + $seen = [...$visited, $fqcn]; + $direct = $class->parent === null ? $class->interfaces : [$class->parent, ...$class->interfaces]; + + $supertypes = $direct; + foreach ($direct as $supertype) { + $supertypes = [...$supertypes, ...$this->supertypesOf($supertype, $seen)]; + } + + return $supertypes; + } + + /** + * @return list + */ + private function concreteClasses(): array + { + $concrete = []; + foreach ($this->index->allClasses() as $class) { + if ($class->kind === ClassKind::ConcreteClass || $class->kind === ClassKind::Enum_) { + $concrete[] = $class; + } + } + + return $concrete; + } +} diff --git a/src/Resolve/ExternalTarget.php b/src/Resolve/ExternalTarget.php new file mode 100644 index 0000000..e2758c7 --- /dev/null +++ b/src/Resolve/ExternalTarget.php @@ -0,0 +1,23 @@ +detail; + } +} diff --git a/src/Resolve/ReceiverTyper.php b/src/Resolve/ReceiverTyper.php new file mode 100644 index 0000000..44d770c --- /dev/null +++ b/src/Resolve/ReceiverTyper.php @@ -0,0 +1,53 @@ +class; + } + + if ($hint === null || $hint === 'raw') { + return null; + } + + $param = $caller->paramNamed($hint); + if ($param !== null) { + return $this->singleClassType($param->type); + } + + return $hint; + } + + private function singleClassType(?string $type): ?string + { + if ($type === null) { + return null; + } + + if (in_array(strtolower($type), self::BUILTIN_TYPES, true)) { + return null; + } + + return $type; + } +} diff --git a/src/Resolve/Resolution.php b/src/Resolve/Resolution.php new file mode 100644 index 0000000..73734cc --- /dev/null +++ b/src/Resolve/Resolution.php @@ -0,0 +1,18 @@ +fqmn; + } +} diff --git a/src/Resolve/TruncatedResolution.php b/src/Resolve/TruncatedResolution.php new file mode 100644 index 0000000..1e7dad0 --- /dev/null +++ b/src/Resolve/TruncatedResolution.php @@ -0,0 +1,23 @@ +reason; + } +} diff --git a/tests/Chain/ChainBuilderTest.php b/tests/Chain/ChainBuilderTest.php new file mode 100644 index 0000000..8ccd9a8 --- /dev/null +++ b/tests/Chain/ChainBuilderTest.php @@ -0,0 +1,248 @@ +file !== '' && is_file($this->file)) { + unlink($this->file); + } + } + + /** + * @return list + */ + private function build(string $code): array + { + $this->file = tempnam(sys_get_temp_dir(), 'phptramp') . '.php'; + file_put_contents($this->file, $code); + $index = (new Indexer())->index([$this->file]); + $resolver = new CallResolver($index, new ClassHierarchy($index)); + + return (new ChainBuilder($resolver))->build($index); + } + + public function testReadmeThreeHopChain(): void + { + $code = 'process($config); } } ' + . 'class ServiceA { public function process(Cfg $config): void { (new ServiceB())->run($config); } } ' + . 'class ServiceB { public function run(Cfg $config): void { new Mailer($config); } } ' + . 'class Mailer { private Cfg $c; ' + . 'public function __construct(Cfg $config) { $this->c = $config; } }'; + + $findings = $this->build($code); + self::assertCount(1, $findings); + + $finding = $findings[0]; + self::assertSame('config', $finding->param); + self::assertSame('Demo\Controller::handle', $finding->origin); + self::assertSame('Demo\Mailer::__construct', $finding->terminal); + self::assertSame(TerminalKind::Stored, $finding->terminalKind); + self::assertSame(3, $finding->hops); + self::assertSame(4, $finding->classes); + self::assertCount(4, $finding->chain); + self::assertSame([ + 'Demo\Controller::handle: method:process -> Demo\ServiceA::process', + 'Demo\ServiceA::process: method:run -> Demo\ServiceB::run', + 'Demo\ServiceB::run: new:Demo\Mailer -> Demo\Mailer::__construct', + ], $finding->trace); + } + + public function testUseAtSecondHopEndsChain(): void + { + $code = 'step($p); } } ' + . 'class B { public function step(Cfg $p): void { $p->run(); } }'; + + $findings = $this->build($code); + self::assertCount(1, $findings); + self::assertSame(1, $findings[0]->hops); + self::assertSame('Demo\B::step', $findings[0]->terminal); + self::assertSame(TerminalKind::Used, $findings[0]->terminalKind); + } + + public function testBranchToTwoCalleesYieldsTwoFindings(): void + { + $code = 'left($p); (new C())->right($p); } } ' + . 'class B { public function left(Cfg $p): void { $p->x(); } } ' + . 'class C { public function right(Cfg $p): void { $p->y(); } }'; + + $findings = $this->build($code); + self::assertCount(2, $findings); + self::assertSame('Demo\A::go', $findings[0]->origin); + self::assertSame('Demo\A::go', $findings[1]->origin); + $terminals = [$findings[0]->terminal, $findings[1]->terminal]; + sort($terminals); + self::assertSame(['Demo\B::left', 'Demo\C::right'], $terminals); + } + + public function testChainStartsAtTheTrueOriginRegardlessOfDeclarationOrder(): void + { + // The origin method is declared last, after a mid-chain node. Proper + // origin detection (no incoming edge) must still start the chain at it — + // not at whichever pure-forward node is indexed first. + $code = 'store($config); } } ' + . 'class Sink { public function store(Cfg $config): void { $config->x(); } } ' + . 'class Origin { public function start(Cfg $config): void { (new Mid())->relay($config); } }'; + + $findings = $this->build($code); + self::assertCount(1, $findings); + self::assertSame('Demo\Origin::start', $findings[0]->origin); + self::assertSame(2, $findings[0]->hops); + } + + public function testUsedTerminalThatAlsoForwardsDoesNotContinuePastIt(): void + { + // B::step reads $p (so it is a Used terminal) but also forwards it. The + // chain ends at the use; it must not walk on into C. + $code = 'step($p); } } ' + . 'class B { public function step(Cfg $p): void { $p->x(); (new C())->y($p); } } ' + . 'class C { public function y(Cfg $p): void { $p->z(); } }'; + + $findings = $this->build($code); + self::assertCount(1, $findings); + self::assertSame('Demo\B::step', $findings[0]->terminal); + self::assertSame(TerminalKind::Used, $findings[0]->terminalKind); + } + + public function testByRefCalleeTerminatesAsByRef(): void + { + $code = 'mut($p); } } ' + . 'class B { public function mut(Cfg &$p): void {} }'; + + $findings = $this->build($code); + self::assertCount(1, $findings); + self::assertSame(TerminalKind::ByRef, $findings[0]->terminalKind); + self::assertSame('Demo\B::mut', $findings[0]->terminal); + } + + public function testUnusedCalleeTerminatesAsUnused(): void + { + $code = 'ignore($p); } } ' + . 'class B { public function ignore(Cfg $p): void {} }'; + + $findings = $this->build($code); + self::assertCount(1, $findings); + self::assertSame(TerminalKind::Unused, $findings[0]->terminalKind); + } + + public function testForwardIntoExternalFunctionTerminatesAsExternal(): void + { + $code = 'build($code); + self::assertCount(1, $findings); + self::assertSame(TerminalKind::External, $findings[0]->terminalKind); + self::assertNull($findings[0]->terminal); + self::assertCount(1, $findings[0]->chain); + self::assertSame( + ['Demo\A::go: function:sprintf -> external: function not in index'], + $findings[0]->trace, + ); + } + + public function testMultipleImplementationsTruncateWithNote(): void + { + $code = 'x(); } } ' + . 'class H2 implements Handler { public function handle(Cfg $p): void { $p->y(); } } ' + . 'class A { public function go(Handler $h, Cfg $p): void { $h->handle($p); } }'; + + $findings = $this->build($code); + self::assertCount(1, $findings); + self::assertSame(TerminalKind::Truncated, $findings[0]->terminalKind); + self::assertNull($findings[0]->terminal); + self::assertContains('truncated: 2 implementations', $findings[0]->notes); + self::assertSame( + ['Demo\A::go: method:handle -> truncated: 2 implementations'], + $findings[0]->trace, + ); + } + + public function testDirectRecursionIsReportedOnceAndTerminates(): void + { + $code = 'go($p); } }'; + + $findings = $this->build($code); + self::assertCount(1, $findings); + self::assertSame('Demo\A::go', $findings[0]->origin); + self::assertContains('(recursive)', $findings[0]->notes); + } + + public function testEntrylessCycleIsReportedOnce(): void + { + $code = 'b($p); } ' + . 'public function b(Cfg $p): void { $this->a($p); } }'; + + $findings = $this->build($code); + self::assertCount(1, $findings); + self::assertSame('Demo\A::a', $findings[0]->origin); + self::assertContains('(recursive)', $findings[0]->notes); + } + + public function testChainBuilderDoesNotApplyLimit(): void + { + // A single-hop chain must still be returned; thresholding is the caller's job. + $code = 'step($p); } } ' + . 'class B { public function step(Cfg $p): void { $p->run(); } }'; + + $findings = $this->build($code); + self::assertCount(1, $findings); + self::assertSame(1, $findings[0]->hops); + } + + public function testFindingsAreSortedByOriginLocationThenParam(): void + { + // Two independent origins in the same file; ZZ declared first but on a + // later line, so line-order (not declaration text) drives the sort. + $code = 'take($p); } } ' + . 'class Late { public function go(Cfg $p): void { (new Sink())->take($p); } } ' + . 'class Sink { public function take(Cfg $p): void { $p->x(); } }'; + + $findings = $this->build($code); + self::assertCount(2, $findings); + self::assertSame('Demo\Early::go', $findings[0]->origin); + self::assertSame('Demo\Late::go', $findings[1]->origin); + } + + public function testFindingsFromOneOriginAreSortedByParamName(): void + { + // Both params originate on the same method (same file and line), so only + // the parameter name breaks the tie; 'aaa' must sort before 'bbb' even + // though 'bbb' is declared first. + $code = 'x($bbb); (new S())->y($aaa); } } ' + . 'class S { public function x(Cfg $p): void { $p->u(); } public function y(Cfg $p): void { $p->u(); } }'; + + $findings = $this->build($code); + self::assertCount(2, $findings); + self::assertSame('aaa', $findings[0]->param); + self::assertSame('bbb', $findings[1]->param); + } +} diff --git a/tests/Console/ApplicationTest.php b/tests/Console/ApplicationTest.php index 2455635..4f7ed30 100644 --- a/tests/Console/ApplicationTest.php +++ b/tests/Console/ApplicationTest.php @@ -17,6 +17,19 @@ final class ApplicationTest extends TestCase private Application $app; + /** @var list */ + private array $folders = []; + + protected function tearDown(): void + { + foreach ($this->folders as $folder) { + array_map('unlink', glob($folder . '/*') ?: []); + if (is_dir($folder)) { + rmdir($folder); + } + } + } + protected function setUp(): void { $stdout = fopen('php://memory', 'w+'); @@ -68,10 +81,44 @@ public function testNoArgumentsPrintsHelpAndExitsZero(): void self::assertStringContainsString('Usage:', self::contents($this->stdout)); } - public function testUnimplementedInvocationExitsWithToolErrorCode(): void + public function testDefaultRunReportsChainsAndExitsOne(): void { - self::assertSame(2, $this->app->run(['phptramp', '--folder', 'src'])); + $folder = $this->fixtureWithThreeHopChain(); + + self::assertSame(1, $this->app->run(['phptramp', '--folder', $folder])); + self::assertStringContainsString('FINDING', self::contents($this->stdout)); + } + + public function testLimitAboveChainLengthReportsNothingAndExitsZero(): void + { + $folder = $this->fixtureWithThreeHopChain(); + + self::assertSame(0, $this->app->run(['phptramp', '--folder', $folder, '--limit', '4'])); + self::assertStringContainsString('No tramp data found', self::contents($this->stdout)); + } + + public function testUnsupportedFormatExitsWithToolErrorCode(): void + { + $folder = $this->fixtureWithThreeHopChain(); + + self::assertSame(2, $this->app->run(['phptramp', '--folder', $folder, '--format', 'json'])); self::assertStringContainsString('not implemented', self::contents($this->stderr)); self::assertSame('', self::contents($this->stdout)); } + + private function fixtureWithThreeHopChain(): string + { + $folder = sys_get_temp_dir() . '/phptramp-app-' . uniqid(); + mkdir($folder); + $this->folders[] = $folder; + + $code = 'process($config); } } ' + . 'class ServiceA { public function process(Cfg $config): void { (new ServiceB())->run($config); } } ' + . 'class ServiceB { public function run(Cfg $config): void { new Mailer($config); } } ' + . 'class Mailer { private Cfg $c; public function __construct(Cfg $config) { $this->c = $config; } }'; + file_put_contents($folder . '/Demo.php', $code); + + return $folder; + } } diff --git a/tests/FixtureTest.php b/tests/FixtureTest.php index deea65b..7c56e1d 100644 --- a/tests/FixtureTest.php +++ b/tests/FixtureTest.php @@ -4,15 +4,24 @@ namespace PhpTramp\Tests; +use PhpTramp\Chain\ChainBuilder; +use PhpTramp\Chain\Finding; +use PhpTramp\Chain\Hop; use PhpTramp\Console\Application; +use PhpTramp\Console\Options; +use PhpTramp\Discovery\FileLocator; +use PhpTramp\Index\Indexer; +use PhpTramp\Resolve\CallResolver; +use PhpTramp\Resolve\ClassHierarchy; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; /** - * Fixture harness: runs the real CLI pipeline over every - * tests/fixtures//src and compares its normalized `--dump-index` output - * against the case's expected-index.json. Adding a semantic rule means adding a - * fixture directory, not touching this test. + * Fixture harness: runs the real pipeline over every tests/fixtures//src. + * A case with expected-index.json pins the classifier's `--dump-index` output; a + * case with expected-findings.json pins the chain builder's findings (chain + * flattened to FQMNs — the CLI JSON format arrives in Phase 3). Adding a semantic + * rule means adding a fixture directory, not touching this test. */ final class FixtureTest extends TestCase { @@ -43,6 +52,58 @@ public function testDumpIndexMatchesExpectation(string $caseDir): void self::assertEquals($expected, $actual); } + /** + * @return iterable + */ + public static function findingsFixtureProvider(): iterable + { + $pattern = __DIR__ . '/fixtures/*/expected-findings.json'; + foreach (glob($pattern) ?: [] as $expectedFile) { + $caseDir = dirname($expectedFile); + yield basename($caseDir) => [$caseDir]; + } + } + + #[DataProvider('findingsFixtureProvider')] + public function testFindingsMatchExpectation(string $caseDir): void + { + $expectedJson = file_get_contents($caseDir . '/expected-findings.json'); + self::assertIsString($expectedJson); + $expected = json_decode($expectedJson, true); + + self::assertEquals($expected, $this->findings($caseDir . '/src')); + } + + /** + * @return array{findings: list>} + */ + private function findings(string $srcDir): array + { + $files = (new FileLocator())->locate(new Options(folders: [$srcDir])); + $index = (new Indexer())->index($files); + $resolver = new CallResolver($index, new ClassHierarchy($index)); + $findings = (new ChainBuilder($resolver))->build($index); + + return ['findings' => array_map($this->normalizeFinding(...), $findings)]; + } + + /** + * @return array + */ + private function normalizeFinding(Finding $finding): array + { + return [ + 'param' => $finding->param, + 'origin' => $finding->origin, + 'terminal' => $finding->terminal, + 'terminalKind' => $finding->terminalKind->value, + 'hops' => $finding->hops, + 'chain' => array_map(static fn (Hop $hop): string => $hop->fqmn, $finding->chain), + 'classes' => $finding->classes, + 'notes' => $finding->notes, + ]; + } + /** * @return array */ diff --git a/tests/Index/IndexerTest.php b/tests/Index/IndexerTest.php index 37437a1..42d5235 100644 --- a/tests/Index/IndexerTest.php +++ b/tests/Index/IndexerTest.php @@ -4,6 +4,7 @@ namespace PhpTramp\Tests\Index; +use PhpTramp\Index\ClassKind; use PhpTramp\Index\Indexer; use PhpTramp\Index\MethodIndex; use PhpTramp\Index\ParseException; @@ -129,6 +130,44 @@ public function testRecordsClassHierarchyFacts(): void self::assertSame(['Demo\Logs'], $service->traits); } + public function testRecordsClassKindForEveryDeclarationShape(): void + { + $code = <<<'PHP' + index($code); + + self::assertSame(ClassKind::ConcreteClass, $index->classInfo('Demo\Concrete')?->kind); + self::assertSame(ClassKind::AbstractClass, $index->classInfo('Demo\Abstracted')?->kind); + self::assertSame(ClassKind::Interface_, $index->classInfo('Demo\Contract')?->kind); + self::assertSame(ClassKind::Trait_, $index->classInfo('Demo\Helper')?->kind); + self::assertSame(ClassKind::Enum_, $index->classInfo('Demo\Suit')?->kind); + } + public function testParseErrorThrowsParseException(): void { $this->expectException(ParseException::class); diff --git a/tests/Index/UsageClassifierTest.php b/tests/Index/UsageClassifierTest.php index 362ec6f..773750d 100644 --- a/tests/Index/UsageClassifierTest.php +++ b/tests/Index/UsageClassifierTest.php @@ -228,6 +228,68 @@ public function testUntouchedParamIsUnused(): void self::assertSame(ParamFate::Unused, $this->classify('log(1);')['p']->fate); } + public function testPropertyStoreOnlyIsStoredOnly(): void + { + $p = $this->classify('$this->x = $p;')['p']; + self::assertSame(ParamFate::Used, $p->fate); + self::assertTrue($p->storedOnly); + } + + public function testStorePlusReadIsNotStoredOnly(): void + { + self::assertFalse($this->classify('$this->x = $p; log($p->env);')['p']->storedOnly); + } + + public function testPureForwardIsNotStoredOnly(): void + { + self::assertFalse($this->classify('other($p);')['p']->storedOnly); + } + + public function testPlainForwardingUseIsNotStoredOnly(): void + { + self::assertFalse($this->classify('log($p);')['p']->storedOnly); + } + + public function testUntouchedParamIsNotStoredOnly(): void + { + self::assertFalse($this->classify('log(1);')['p']->storedOnly); + } + + public function testStoreThatAlsoForwardsIsNotStoredOnly(): void + { + self::assertFalse($this->classify('$this->x = $p; other($p);')['p']->storedOnly); + } + + public function testClosureCaptureIsNotStoredOnly(): void + { + self::assertFalse($this->classify('$f = function () use ($p) { other($p); };')['p']->storedOnly); + } + + public function testArrowFnReferenceIsNotStoredOnly(): void + { + self::assertFalse($this->classify('$f = fn () => other($p);')['p']->storedOnly); + } + + public function testByRefParamIsNotStoredOnly(): void + { + self::assertFalse($this->classify('other($p);', 'Cfg &$p')['p']->storedOnly); + } + + public function testPromotedConstructorPropertyIsStoredOnly(): void + { + $code = 'index([$file])->get('Mailer::__construct'); + self::assertNotNull($method); + self::assertTrue($method->params[0]->storedOnly); + } finally { + unlink($file); + } + } + public function testConstructorPropertyStorageIsUsed(): void { $code = 'render([$finding])); + } + + public function testRendersTruncatedChainWithNoteLineAndNoTerminal(): void + { + $chain = [ + new Hop('Demo\A::go', 'Demo\A', 'src/A.php', 5, 7), + new Hop('Demo\B::step', 'Demo\B', 'src/B.php', 9, 11), + ]; + $finding = new Finding( + 'cfg', + 'Demo\A::go', + null, + TerminalKind::Truncated, + 2, + $chain, + 2, + ['truncated: 2 implementations'], + [], + ); + + $expected = <<<'TXT' + FINDING $cfg: 2 pass-through hops across 2 classes + origin Demo\A::go($cfg) src/A.php:5 + hop 2 Demo\B::step($cfg) src/B.php:9 + note truncated: 2 implementations + + 1 finding (limit: 3 hops). + + TXT; + + self::assertSame($expected, (new TextReporter(3))->render([$finding])); + } + + public function testRendersSingularFormsForOneHopOneClassOneFinding(): void + { + $chain = [ + new Hop('Demo\A::go', 'Demo\A', 'src/A.php', 5, 7), + new Hop('Demo\A::sink', 'Demo\A', 'src/A.php', 9, null), + ]; + $finding = new Finding('p', 'Demo\A::go', 'Demo\A::sink', TerminalKind::Used, 1, $chain, 1, [], []); + + $expected = <<<'TXT' + FINDING $p: 1 pass-through hop across 1 class + origin Demo\A::go($p) src/A.php:5 + terminal Demo\A::sink($p) src/A.php:9 (used) + + 1 finding (limit: 1 hop). + + TXT; + + self::assertSame($expected, (new TextReporter(1))->render([$finding])); + } + + public function testExplainAppendsTraceLinesUnderHeader(): void + { + $chain = [ + new Hop('Demo\A::go', 'Demo\A', 'src/A.php', 5, 7), + new Hop('Demo\B::step', 'Demo\B', 'src/B.php', 9, null), + ]; + $trace = ['Demo\A::go: method:step -> Demo\B::step']; + $finding = new Finding('p', 'Demo\A::go', 'Demo\B::step', TerminalKind::Used, 1, $chain, 2, [], $trace); + + $expected = <<<'TXT' + FINDING $p: 1 pass-through hop across 2 classes + origin Demo\A::go($p) src/A.php:5 + terminal Demo\B::step($p) src/B.php:9 (used) + explain: + Demo\A::go: method:step -> Demo\B::step + + 1 finding (limit: 3 hops). + + TXT; + + self::assertSame($expected, (new TextReporter(3, true))->render([$finding])); + } + + public function testExplainDisabledOmitsTraceEvenWhenPresent(): void + { + $chain = [ + new Hop('Demo\A::go', 'Demo\A', 'src/A.php', 5, 7), + new Hop('Demo\B::step', 'Demo\B', 'src/B.php', 9, null), + ]; + $trace = ['Demo\A::go: method:step -> Demo\B::step']; + $finding = new Finding('p', 'Demo\A::go', 'Demo\B::step', TerminalKind::Used, 1, $chain, 2, [], $trace); + + self::assertStringNotContainsString('explain:', (new TextReporter(3))->render([$finding])); + } + + public function testEmptyFindingsRendersReassuringLine(): void + { + self::assertSame( + "No tramp data found (limit: 3 hops).\n", + (new TextReporter(3))->render([]), + ); + } +} diff --git a/tests/Resolve/CallResolverTest.php b/tests/Resolve/CallResolverTest.php new file mode 100644 index 0000000..59ca8d1 --- /dev/null +++ b/tests/Resolve/CallResolverTest.php @@ -0,0 +1,281 @@ +file !== '' && is_file($this->file)) { + unlink($this->file); + } + } + + private function resolve(string $code, string $callerFqmn, string $param = 'p'): Resolution + { + $this->file = tempnam(sys_get_temp_dir(), 'phptramp') . '.php'; + file_put_contents($this->file, $code); + $index = (new Indexer())->index([$this->file]); + + $caller = $index->get($callerFqmn); + self::assertNotNull($caller); + $forward = $this->firstForward($caller, $param); + + return (new CallResolver($index, new ClassHierarchy($index)))->resolve($forward, $caller); + } + + private function firstForward(MethodInfo $caller, string $param): ForwardSite + { + foreach ($caller->params as $candidate) { + if ($candidate->name === $param) { + self::assertNotEmpty($candidate->forwards, "param {$param} has no forwards"); + + return $candidate->forwards[0]; + } + } + + self::fail("no param {$param} on {$caller->fqmn}"); + } + + public function testFunctionInIndexResolvesToTarget(): void + { + $code = 'resolve($code, 'Demo\Caller::go'); + self::assertInstanceOf(ResolvedTarget::class, $resolution); + self::assertSame('Demo\helper', $resolution->fqmn); + self::assertSame('x', $resolution->boundParam); + } + + public function testUnknownFunctionIsExternal(): void + { + $code = 'resolve($code, 'Demo\Caller::go')); + } + + public function testStaticCallViaClassName(): void + { + $code = 'resolve($code, 'Demo\Caller::go'); + self::assertInstanceOf(ResolvedTarget::class, $resolution); + self::assertSame('Demo\Other::run', $resolution->fqmn); + } + + public function testStaticCallInheritedFromParent(): void + { + $code = 'resolve($code, 'Demo\Child::go'); + self::assertInstanceOf(ResolvedTarget::class, $resolution); + self::assertSame('Demo\Base::make', $resolution->fqmn); + } + + public function testStaticCallViaStaticKeyword(): void + { + $code = 'resolve($code, 'Demo\Child::go'); + self::assertInstanceOf(ResolvedTarget::class, $resolution); + self::assertSame('Demo\Base::make', $resolution->fqmn); + } + + public function testStaticCallViaParentKeyword(): void + { + $code = 'resolve($code, 'Demo\Child::go'); + self::assertInstanceOf(ResolvedTarget::class, $resolution); + self::assertSame('Demo\Base::make', $resolution->fqmn); + } + + public function testFunctionCallerResolvesNamespacedFunction(): void + { + // The caller is itself a namespaced function; its namespace must come from + // its own FQMN, not a declaring class (it has none). + $code = 'resolve($code, 'Demo\outer'); + self::assertInstanceOf(ResolvedTarget::class, $resolution); + self::assertSame('Demo\inner', $resolution->fqmn); + } + + public function testMethodOnThis(): void + { + $code = 'sink($p); } ' + . 'public function sink(Cfg $x): void {} }'; + + $resolution = $this->resolve($code, 'Demo\Caller::go'); + self::assertInstanceOf(ResolvedTarget::class, $resolution); + self::assertSame('Demo\Caller::sink', $resolution->fqmn); + } + + public function testMethodViaParamDeclaredType(): void + { + $code = 'handle($p); } }'; + + $resolution = $this->resolve($code, 'Demo\Caller::go'); + self::assertInstanceOf(ResolvedTarget::class, $resolution); + self::assertSame('Demo\Service::handle', $resolution->fqmn); + } + + public function testMethodViaInlineNewReceiver(): void + { + $code = 'handle($p); } }'; + + $resolution = $this->resolve($code, 'Demo\Caller::go'); + self::assertInstanceOf(ResolvedTarget::class, $resolution); + self::assertSame('Demo\Service::handle', $resolution->fqmn); + } + + public function testInterfaceWithSingleImplementationResolvesIntoIt(): void + { + $code = 'handle($p); } }'; + + $resolution = $this->resolve($code, 'Demo\Caller::go'); + self::assertInstanceOf(ResolvedTarget::class, $resolution); + self::assertSame('Demo\RealHandler::handle', $resolution->fqmn); + } + + public function testInterfaceWithTwoImplementationsTruncatesWithCount(): void + { + $code = 'handle($p); } }'; + + $resolution = $this->resolve($code, 'Demo\Caller::go'); + self::assertInstanceOf(TruncatedResolution::class, $resolution); + self::assertStringContainsString('2', $resolution->reason); + } + + public function testInterfaceWithNoImplementationIsExternal(): void + { + $code = 'handle($p); } }'; + + self::assertInstanceOf(ExternalTarget::class, $this->resolve($code, 'Demo\Caller::go')); + } + + public function testUnionTypedReceiverTruncatesAsUnresolvableType(): void + { + $code = 'handle($p); } }'; + + $resolution = $this->resolve($code, 'Demo\Caller::go'); + self::assertInstanceOf(TruncatedResolution::class, $resolution); + self::assertSame('unresolvable type', $resolution->reason); + } + + public function testStaticCallOnClassNotInIndexIsExternal(): void + { + $code = 'resolve($code, 'Demo\Caller::go')); + } + + public function testRawReceiverTruncatesAsUntyped(): void + { + $code = 'make()->handle($p); } ' + . 'public function make(): Service { return new Service(); } }'; + + $resolution = $this->resolve($code, 'Demo\Caller::go'); + self::assertInstanceOf(TruncatedResolution::class, $resolution); + self::assertSame('untyped receiver', $resolution->reason); + } + + public function testNewWithConstructorResolvesToConstructor(): void + { + $code = 'resolve($code, 'Demo\Caller::go'); + self::assertInstanceOf(ResolvedTarget::class, $resolution); + self::assertSame('Demo\Mailer::__construct', $resolution->fqmn); + self::assertSame('x', $resolution->boundParam); + } + + public function testNewWithoutConstructorIsExternal(): void + { + $code = 'resolve($code, 'Demo\Caller::go')); + } + + public function testNamedArgumentBindsByName(): void + { + $code = 'handle(cfg: $p); } }'; + + $resolution = $this->resolve($code, 'Demo\Caller::go'); + self::assertInstanceOf(ResolvedTarget::class, $resolution); + self::assertSame('cfg', $resolution->boundParam); + } + + public function testPositionalArgumentPastVariadicBindsToVariadic(): void + { + $code = 'handle(1, 2, $p); } }'; + + $resolution = $this->resolve($code, 'Demo\Caller::go'); + self::assertInstanceOf(ResolvedTarget::class, $resolution); + self::assertSame('rest', $resolution->boundParam); + } + + public function testPositionalArgumentBeyondLastParamTruncates(): void + { + $code = 'handle(1, $p); } }'; + + self::assertInstanceOf(TruncatedResolution::class, $this->resolve($code, 'Demo\Caller::go')); + } +} diff --git a/tests/Resolve/ClassHierarchyTest.php b/tests/Resolve/ClassHierarchyTest.php new file mode 100644 index 0000000..e9caae3 --- /dev/null +++ b/tests/Resolve/ClassHierarchyTest.php @@ -0,0 +1,187 @@ +file !== '' && is_file($this->file)) { + unlink($this->file); + } + } + + private function hierarchy(string $code): ClassHierarchy + { + return new ClassHierarchy($this->index($code)); + } + + private function index(string $code): MethodIndex + { + $this->file = tempnam(sys_get_temp_dir(), 'phptramp') . '.php'; + file_put_contents($this->file, $code); + + return (new Indexer())->index([$this->file]); + } + + public function testMethodOnFindsDirectDeclaration(): void + { + $method = $this->hierarchy(self::METHODS)->methodOn('Demo\Leaf', 'direct'); + self::assertSame('Demo\Leaf::direct', $method?->fqmn); + } + + public function testMethodOnFindsViaTrait(): void + { + $method = $this->hierarchy(self::METHODS)->methodOn('Demo\Leaf', 'fromTrait'); + self::assertSame('Demo\Helper::fromTrait', $method?->fqmn); + } + + public function testMethodOnFindsViaParent(): void + { + $method = $this->hierarchy(self::METHODS)->methodOn('Demo\Leaf', 'fromMiddle'); + self::assertSame('Demo\Middle::fromMiddle', $method?->fqmn); + } + + public function testMethodOnFindsViaGrandparent(): void + { + $method = $this->hierarchy(self::METHODS)->methodOn('Demo\Leaf', 'fromAncestor'); + self::assertSame('Demo\Ancestor::fromAncestor', $method?->fqmn); + } + + public function testMethodOnPrefersTraitOverInheritedParentMethod(): void + { + $method = $this->hierarchy(self::METHODS)->methodOn('Demo\Leaf', 'shared'); + self::assertSame('Demo\Helper::shared', $method?->fqmn); + } + + public function testMethodOnUnknownClassIsNull(): void + { + self::assertNull($this->hierarchy(self::METHODS)->methodOn('Demo\Nonexistent', 'direct')); + } + + public function testImplementationsOfInterfaceWithSingleImpl(): void + { + self::assertSame(['Demo\OnlySingle'], $this->hierarchy(self::KINDS)->implementationsOf('Demo\Single')); + } + + public function testImplementationsOfInterfaceWithTwoImplsSorted(): void + { + $implementations = $this->hierarchy(self::KINDS)->implementationsOf('Demo\Pair'); + self::assertSame(['Demo\AlphaPair', 'Demo\BravoPair'], $implementations); + } + + public function testImplementationsOfViaInterfaceExtendsChain(): void + { + self::assertSame(['Demo\ViaDerived'], $this->hierarchy(self::KINDS)->implementationsOf('Demo\Base')); + } + + public function testImplementationsOfAbstractClassViaChainExcludesAbstractLinks(): void + { + $implementations = $this->hierarchy(self::KINDS)->implementationsOf('Demo\AbstractRoot'); + self::assertSame(['Demo\ConcreteLeaf'], $implementations); + } + + public function testImplementationsOfUnknownTypeIsEmpty(): void + { + self::assertSame([], $this->hierarchy(self::KINDS)->implementationsOf('Demo\Nonexistent')); + } + + public function testCyclicInheritanceTerminatesWithoutHanging(): void + { + // Malformed input: A extends B extends A. The visited set must stop both + // the method walk and the supertype walk from looping forever. + $hierarchy = $this->hierarchy('methodOn('Demo\Node', 'ghost')); + self::assertContains('Demo\Node', $hierarchy->implementationsOf('Demo\Node')); + } + + public function testIsAbstractTypeForEveryKind(): void + { + $hierarchy = $this->hierarchy(self::KINDS); + + self::assertTrue($hierarchy->isAbstractType('Demo\Single'), 'interface'); + self::assertTrue($hierarchy->isAbstractType('Demo\AbstractRoot'), 'abstract class'); + self::assertFalse($hierarchy->isAbstractType('Demo\OnlySingle'), 'concrete class'); + self::assertFalse($hierarchy->isAbstractType('Demo\SomeTrait'), 'trait'); + self::assertFalse($hierarchy->isAbstractType('Demo\Suit'), 'enum'); + self::assertFalse($hierarchy->isAbstractType('Demo\Nonexistent'), 'unknown'); + } +} diff --git a/tests/fixtures/classifier-smoke/expected-findings.json b/tests/fixtures/classifier-smoke/expected-findings.json new file mode 100644 index 0000000..5da1fc0 --- /dev/null +++ b/tests/fixtures/classifier-smoke/expected-findings.json @@ -0,0 +1,14 @@ +{ + "findings": [ + { + "param": "config", + "origin": "Demo\\Controller::handle", + "terminal": "Demo\\Mailer::__construct", + "terminalKind": "stored", + "hops": 3, + "chain": ["Demo\\Controller::handle", "Demo\\ServiceA::process", "Demo\\ServiceB::run", "Demo\\Mailer::__construct"], + "classes": 4, + "notes": [] + } + ] +} diff --git a/tests/fixtures/interface-multi-impl/expected-findings.json b/tests/fixtures/interface-multi-impl/expected-findings.json new file mode 100644 index 0000000..3ef9099 --- /dev/null +++ b/tests/fixtures/interface-multi-impl/expected-findings.json @@ -0,0 +1,14 @@ +{ + "findings": [ + { + "param": "config", + "origin": "Demo\\Controller::run", + "terminal": null, + "terminalKind": "truncated", + "hops": 1, + "chain": ["Demo\\Controller::run"], + "classes": 1, + "notes": ["truncated: 2 implementations"] + } + ] +} diff --git a/tests/fixtures/interface-multi-impl/src/Demo.php b/tests/fixtures/interface-multi-impl/src/Demo.php new file mode 100644 index 0000000..d9fd87f --- /dev/null +++ b/tests/fixtures/interface-multi-impl/src/Demo.php @@ -0,0 +1,36 @@ +a(); + } +} + +class HandlerB implements Handler +{ + public function handle(Cfg $config): void + { + $config->b(); + } +} + +class Controller +{ + public function run(Handler $handler, Cfg $config): void + { + $handler->handle($config); + } +} diff --git a/tests/fixtures/interface-single-impl/expected-findings.json b/tests/fixtures/interface-single-impl/expected-findings.json new file mode 100644 index 0000000..a8dd48a --- /dev/null +++ b/tests/fixtures/interface-single-impl/expected-findings.json @@ -0,0 +1,14 @@ +{ + "findings": [ + { + "param": "config", + "origin": "Demo\\Controller::run", + "terminal": "Demo\\RealHandler::handle", + "terminalKind": "used", + "hops": 1, + "chain": ["Demo\\Controller::run", "Demo\\RealHandler::handle"], + "classes": 2, + "notes": [] + } + ] +} diff --git a/tests/fixtures/interface-single-impl/src/Demo.php b/tests/fixtures/interface-single-impl/src/Demo.php new file mode 100644 index 0000000..70e720c --- /dev/null +++ b/tests/fixtures/interface-single-impl/src/Demo.php @@ -0,0 +1,28 @@ +send(); + } +} + +class Controller +{ + public function run(Handler $handler, Cfg $config): void + { + $handler->handle($config); + } +} diff --git a/tests/fixtures/recursion/expected-findings.json b/tests/fixtures/recursion/expected-findings.json new file mode 100644 index 0000000..3841b30 --- /dev/null +++ b/tests/fixtures/recursion/expected-findings.json @@ -0,0 +1,14 @@ +{ + "findings": [ + { + "param": "config", + "origin": "Demo\\Looper::go", + "terminal": null, + "terminalKind": "truncated", + "hops": 1, + "chain": ["Demo\\Looper::go"], + "classes": 1, + "notes": ["(recursive)"] + } + ] +} diff --git a/tests/fixtures/recursion/src/Demo.php b/tests/fixtures/recursion/src/Demo.php new file mode 100644 index 0000000..2ed4e0f --- /dev/null +++ b/tests/fixtures/recursion/src/Demo.php @@ -0,0 +1,15 @@ +go($config); + } +} diff --git a/tests/fixtures/static-function-mix/expected-findings.json b/tests/fixtures/static-function-mix/expected-findings.json new file mode 100644 index 0000000..5f8e923 --- /dev/null +++ b/tests/fixtures/static-function-mix/expected-findings.json @@ -0,0 +1,14 @@ +{ + "findings": [ + { + "param": "config", + "origin": "Demo\\entry", + "terminal": "Demo\\Sink::__construct", + "terminalKind": "stored", + "hops": 2, + "chain": ["Demo\\entry", "Demo\\Middle::relay", "Demo\\Sink::__construct"], + "classes": 2, + "notes": [] + } + ] +} diff --git a/tests/fixtures/static-function-mix/src/Demo.php b/tests/fixtures/static-function-mix/src/Demo.php new file mode 100644 index 0000000..cd61b37 --- /dev/null +++ b/tests/fixtures/static-function-mix/src/Demo.php @@ -0,0 +1,30 @@ +config = $config; + } +} diff --git a/tests/fixtures/use-breaks-chain/expected-findings.json b/tests/fixtures/use-breaks-chain/expected-findings.json new file mode 100644 index 0000000..3fa5c02 --- /dev/null +++ b/tests/fixtures/use-breaks-chain/expected-findings.json @@ -0,0 +1,14 @@ +{ + "findings": [ + { + "param": "config", + "origin": "Demo\\Controller::handle", + "terminal": "Demo\\Service::process", + "terminalKind": "used", + "hops": 1, + "chain": ["Demo\\Controller::handle", "Demo\\Service::process"], + "classes": 2, + "notes": [] + } + ] +} diff --git a/tests/fixtures/use-breaks-chain/src/Demo.php b/tests/fixtures/use-breaks-chain/src/Demo.php new file mode 100644 index 0000000..0793545 --- /dev/null +++ b/tests/fixtures/use-breaks-chain/src/Demo.php @@ -0,0 +1,23 @@ +process($config); + } +} + +class Service +{ + public function process(Cfg $config): void + { + $config->send(); + } +} diff --git a/tests/fixtures/variadic-decorator/expected-findings.json b/tests/fixtures/variadic-decorator/expected-findings.json new file mode 100644 index 0000000..5ea1462 --- /dev/null +++ b/tests/fixtures/variadic-decorator/expected-findings.json @@ -0,0 +1,14 @@ +{ + "findings": [ + { + "param": "args", + "origin": "Demo\\Outer::run", + "terminal": "Demo\\Sink::consume", + "terminalKind": "used", + "hops": 2, + "chain": ["Demo\\Outer::run", "Demo\\Middle::run", "Demo\\Sink::consume"], + "classes": 3, + "notes": [] + } + ] +} diff --git a/tests/fixtures/variadic-decorator/src/Demo.php b/tests/fixtures/variadic-decorator/src/Demo.php new file mode 100644 index 0000000..a7bd48f --- /dev/null +++ b/tests/fixtures/variadic-decorator/src/Demo.php @@ -0,0 +1,33 @@ +run(...$args); + } +} + +class Middle +{ + public function run(Cfg ...$args): void + { + (new Sink())->consume(...$args); + } +} + +class Sink +{ + public function consume(Cfg ...$args): void + { + foreach ($args as $arg) { + $arg->go(); + } + } +}