Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
44 changes: 22 additions & 22 deletions docs/plans/phase-2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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;
Expand All @@ -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`

Expand All @@ -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`

Expand Down Expand Up @@ -330,7 +330,7 @@ Bindings and determinism decisions:
`"<callerFqmn>: <kind>:<name> -> <targetFqmn>"` (or `-> external: <detail>` /
`-> truncated: <reason>`).

- [ ] **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
Expand All @@ -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

Expand Down Expand Up @@ -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`

Expand All @@ -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
Expand Down Expand Up @@ -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)`.

Expand Down
29 changes: 29 additions & 0 deletions src/Chain/ChainBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace PhpTramp\Chain;

use PhpTramp\Index\MethodIndex;
use PhpTramp\Resolve\CallResolver;

/**
* Walks the forward graph of an index and emits every maximal tramp-data chain,
* unfiltered — thresholding against `--limit` is the caller's job. Each call to
* {@see build()} runs an independent {@see ChainTraversal} so the builder itself
* stays stateless and re-usable.
*/
final class ChainBuilder
{
public function __construct(private readonly CallResolver $resolver)
{
}

/**
* @return list<Finding>
*/
public function build(MethodIndex $index): array
{
return (new ChainTraversal($index, $this->resolver))->run();
}
}
Loading
Loading