Skip to content
112 changes: 112 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# phptramp

Composer-installable static analyzer that reports **tramp data** — parameters
passed through chains of PHP methods that never use them. Pure CLI, CI
(diff-aware, baseline-able), and IDE use. PHP ≥ 8.2; the only production
dependency is `nikic/php-parser ^5`. See [docs/plan.md](docs/plan.md) for the
phased implementation plan and the frozen core semantics.

## Run the CI checks locally

CI runs exactly these tools — run them locally before you commit or push so a
red pipeline never comes as a surprise. Every one has a `composer` script:

```bash
composer cs # PHP_CodeSniffer, PSR-12 over src/ and tests/ (composer cs:fix to autofix)
composer stan # PHPStan level max over src/ and bin/
composer md # PHPMD, codesize ruleset over src/
composer test # PHPUnit
composer check # cs + stan + md + test — the pre-commit gate
```

`composer check` mirrors the `tests` + `static` CI jobs (the matrix runs the
suite on PHP 8.2 / 8.3 / 8.4; static analysis runs on 8.4). **It must be green
before every commit.**

### Mutation testing — the PR gate that `composer check` does not cover

Pull requests additionally run diff-scoped Infection, and it gates the merge
(`minMsi 80`). `composer check` passing is **not** enough — run the mutation
job locally before opening or updating a PR:

```bash
composer infection # mutation testing over all of src/ (needs pcov or xdebug)
composer infection:diff # over the files this branch changes — exactly what CI gates
```

`infection:diff` diffs against `origin/main`, so `git fetch origin main` first.
An escaped mutant is a test-suite gap on code whose whole product is semantic
correctness — treat it as a real finding: add a test that would fail if that
line were wrong, do not tune the threshold. Inspect escaped mutants in
`infection.log` or with `--show-mutations=max`.

## Layout

| Path | What |
|---|---|
| `bin/phptramp` | CLI entry point |
| `src/Console/` | `ArgvParser` → `Options`, `Application` wiring |
| `src/Discovery/` | `FileLocator` — folders/files → `*.php` list |
| `src/Index/` | php-parser indexing + the parameter usage classifier (the semantic heart) |
| `src/Resolve/`, `src/Chain/` | call resolution + cross-file chain stitching (Phase 2) |
| `tests/**` | mirrors `src/`; `tests/fixtures/<case>/` are input-codebase + expected-output pairs |

## PHP code style — Clean Code is mandatory

**All PHP must follow Clean Code principles.** This is not advisory: a change
that passes the linters but leaves unclear, oversized, or duplicated code is not
done.

Non-negotiables:

- **Names reveal intent.** No abbreviations, no `$data`/`$info`/`$tmp`, no
encodings. If a name needs a comment to be understood, rename it.
- **Functions do one thing**, at a single level of abstraction, and stay short.
Extract until each method reads as a sentence about *what*, not *how*.
- **Few parameters.** Three is a lot; more means a value object is missing.
**No boolean flag parameters** that select behaviour — split the method
instead. (A `bool` that is pure *data* on a value object, e.g. `ParamInfo`'s
`$byRef`, is fine.)
- **Guard clauses over nesting.** Return early; never let indentation carry logic.
- **No hidden side effects.** A method named `get…` does not mutate.
- **Immutability by default.** `final class` with `readonly` promoted
constructor properties is the house style — the value objects in `src/Index/`
and `Options` already are. Prefer new instances over setters. `final` unless
the class is designed for extension.
- **Errors are exceptions**, typed and namespaced next to what raises them
(`InvalidArgsException`, `Index\ParseException`). Never signal failure with
`null` or a magic value — reserve `null` for genuine "absent", as in
`MethodIndex::get()`.
- **Comments explain *why*, never *what*.** The existing comments justify a
defensive branch or record a frozen-semantics decision — match that bar.
Delete commented-out code.
- **DRY.** Third occurrence is a refactor, not a copy.
- **Tests are production code** — same naming, same structure, same standards.
Fixture inputs under `tests/fixtures/` are the exception: they are analyzer
*input data*, excluded from PSR-12.

Enforced mechanically by `composer check` (and the PR mutation gate):

- **PSR-12** (`phpcs.xml.dist`), `declare(strict_types=1)` in every file.
- **PHPStan level max** over `src/` and `bin/` — no baselines, no
`@phpstan-ignore` without a comment saying why. Fix the underlying type, do
not cast or widen to silence the error.
- **PHPMD codesize** — cyclomatic/NPath complexity, method and class length,
parameter/field counts. **Standing rule: every `src/` file you touch must be
PHPMD-clean before commit**, not merely free of *new* findings. Fix the design
the metric points at; do not tune the threshold. A genuine value-object DTO
may carry a scoped `@SuppressWarnings` with a comment (see `Options`).

## Working style

- **Strict fixture-first TDD, one task per commit.** Write the failing test,
confirm it fails for the expected reason, write the minimal code, go green,
run `composer check`, commit. Never batch tasks into one commit.
- **Conventional commits** (`feat:`, `fix:`, `test:`, `refactor:`, `docs:`,
`ci:`); the body explains *why* when it is not obvious.
- **Semantics are frozen in [docs/plan.md](docs/plan.md).** If a case is not
covered there, pick the conservative option (fewer findings), add a fixture
documenting the choice, and flag it in the commit message — never improvise
silently.
- **No second production dependency, and no auto-fix — ever** (both are
deliberate, documented non-goals).
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
> methods that never use them — "this parameter was passed through 4 classes / 5 methods
> before being used."

**Status: Phase 0 (scaffold).** The analysis engine is not implemented yet.
**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.
See [docs/plan.md](docs/plan.md) for the full implementation plan and current phase.

## What it will do
Expand Down
28 changes: 14 additions & 14 deletions docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ skeleton, README with the `composer tramp` script recipe, this plan.

---

## Phase 1 — Usage classifier + index
## Phase 1 — Usage classifier + index ✅

**Deliverable:** `phptramp --folder <dir> --dump-index` prints the classified method
index as JSON. This makes the classifier — the semantic heart — independently shippable,
Expand All @@ -220,7 +220,7 @@ testable, and debuggable before any chain logic exists.
`?string $generateBaseline = null`, `bool $dumpIndex = false`,
`bool $help = false`, `bool $version = false`.

- [ ] **Step 1: Write the failing tests** (`tests/Console/ArgvParserTest.php`):
- [x] **Step 1: Write the failing tests** (`tests/Console/ArgvParserTest.php`):

```php
<?php
Expand Down Expand Up @@ -307,12 +307,12 @@ final class ArgvParserTest extends TestCase
}
```

- [ ] **Step 2:** `vendor/bin/phpunit --filter ArgvParserTest` → FAIL (class not found).
- [ ] **Step 3:** Implement: a `while` loop over args; flags taking values read the next
- [x] **Step 2:** `vendor/bin/phpunit --filter ArgvParserTest` → FAIL (class not found).
- [x] **Step 3:** Implement: a `while` loop over args; flags taking values read the next
element; `--flag=value` split on the first `=`. Valid formats:
`text|json|github|checkstyle|sarif|summary`.
- [ ] **Step 4:** Test passes; `composer check` green.
- [ ] **Step 5:** Commit `feat: argv parser and options object`.
- [x] **Step 4:** Test passes; `composer check` green.
- [x] **Step 5:** Commit `feat: argv parser and options object`.

### Task 1.2: FileLocator

Expand All @@ -325,7 +325,7 @@ final class ArgvParserTest extends TestCase
each folder (RecursiveDirectoryIterator), plus explicit files; deduplicated, sorted,
realpath-normalized; nonexistent path → `InvalidArgsException`.

- [ ] Steps: failing test (folder recursion, dedup, sorted, missing path throws) → red →
- [x] Steps: failing test (folder recursion, dedup, sorted, missing path throws) → red →
implement → green → commit `feat: file discovery`.

### Task 1.3: Indexer skeleton (methods + params, no classification)
Expand All @@ -344,7 +344,7 @@ final class ArgvParserTest extends TestCase
- Uses php-parser `NodeTraverser` + `NameResolver` (FQ names for free), `ParentConnectingVisitor`.
Parse errors → collect and report file + message, exit 2.

- [ ] Steps: failing test (fixture file with namespace, class, trait use, function;
- [x] Steps: failing test (fixture file with namespace, class, trait use, function;
assert FQMNs, param names/positions, by-ref flag, variadic flag, interface list) →
red → implement → green → commit `feat: method index`.

Expand All @@ -370,7 +370,7 @@ final class ArgvParserTest extends TestCase
4. Zero occurrences → `Unused`. Forwards only → `PureForward`. Any use → `Used`
(forwards list kept anyway — Phase 4 needs call-site lines even for terminals).

- [ ] **Step 1: Write the failing tests** (`tests/Index/UsageClassifierTest.php`).
- [x] **Step 1: Write the failing tests** (`tests/Index/UsageClassifierTest.php`).
The helper wraps a method body in a class, runs the real Indexer over a temp file,
and returns the classified params — so these tests pin the *pipeline's* semantics,
not a mock's:
Expand Down Expand Up @@ -510,10 +510,10 @@ final class UsageClassifierTest extends TestCase
a constructor body `$this->x = $p;` → `Used`; same for a promoted property
`public function __construct(private Cfg $p) {}` → `Used`.

- [ ] **Step 2:** run → all red (enum/classes missing).
- [ ] **Step 3:** implement the visitor per the algorithm above.
- [ ] **Step 4:** all green; `composer check` green.
- [ ] **Step 5:** Commit `feat: parameter usage classifier`.
- [x] **Step 2:** run → all red (enum/classes missing).
- [x] **Step 3:** implement the visitor per the algorithm above.
- [x] **Step 4:** all green; `composer check` green.
- [x] **Step 5:** Commit `feat: parameter usage classifier`.

### Task 1.5: `--dump-index` wiring + fixture harness

Expand Down Expand Up @@ -602,7 +602,7 @@ class Mailer

(Methods with no params are omitted from `--dump-index` output to keep it stable.)

- [ ] Steps: failing fixture test → wire pipeline in Application → green → commit
- [x] Steps: failing fixture test → wire pipeline in Application → green → commit
`feat: --dump-index and fixture harness`. **Phase exit:** CI matrix green.

---
Expand Down
80 changes: 75 additions & 5 deletions src/Console/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

namespace PhpTramp\Console;

use PhpTramp\Discovery\FileLocator;
use PhpTramp\Index\ForwardSite;
use PhpTramp\Index\Indexer;
use PhpTramp\Index\MethodIndex;
use PhpTramp\Index\ParamInfo;
use PhpTramp\Index\ParseException;

final class Application
{
public const NAME = 'phptramp';
Expand Down Expand Up @@ -32,23 +39,82 @@
{
$args = array_slice($argv, 1);

if ($args === [] || in_array('--help', $args, true) || in_array('-h', $args, true)) {
try {
$options = (new ArgvParser())->parse($args);
} catch (InvalidArgsException $e) {
fwrite($this->stderr, 'phptramp: ' . $e->getMessage() . "\n");

return 2;
}

if ($args === [] || $options->help) {
fwrite($this->stdout, self::helpText());

return 0;
}

if (in_array('--version', $args, true) || in_array('-V', $args, true)) {
if ($options->version) {
fwrite($this->stdout, self::NAME . ' ' . self::VERSION . "\n");

return 0;
}

fwrite($this->stderr, "phptramp: analysis is not implemented yet (Phase 0 scaffold). See docs/plan.md.\n");
if ($options->dumpIndex) {
return $this->dumpIndex($options);
}

fwrite($this->stderr, "phptramp: chain analysis is not implemented yet (Phase 1). See docs/plan.md.\n");

return 2;
}

private function dumpIndex(Options $options): int
{
try {
$files = (new FileLocator())->locate($options);
$index = (new Indexer())->index($files);
} catch (InvalidArgsException | ParseException $e) {
fwrite($this->stderr, 'phptramp: ' . $e->getMessage() . "\n");

return 2;
}

fwrite($this->stdout, $this->renderIndex($index));

return 0;
}

private function renderIndex(MethodIndex $index): string
{
$methods = [];
foreach ($index->all() as $fqmn => $method) {
if ($method->params === []) {
continue;
}

$methods[$fqmn] = [
'file' => $method->file,
'params' => array_map(
fn (ParamInfo $param): array => [
'name' => $param->name,
'fate' => $param->fate->name,
'forwards' => array_map($this->renderForward(...), $param->forwards),
],
$method->params,
),
];
}

$json = json_encode(['methods' => $methods], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);

Check warning on line 108 in src/Console/Application.php

View workflow job for this annotation

GitHub Actions / Mutation (changed files)

Escaped Mutant for Mutator "BitwiseOr": @@ @@ ]; } - $json = json_encode(['methods' => $methods], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $json = json_encode(['methods' => $methods], JSON_PRETTY_PRINT & JSON_UNESCAPED_SLASHES); return ($json === false ? '{"methods":{}}' : $json) . "\n"; }

return ($json === false ? '{"methods":{}}' : $json) . "\n";

Check warning on line 110 in src/Console/Application.php

View workflow job for this annotation

GitHub Actions / Mutation (changed files)

Escaped Mutant for Mutator "ConcatOperandRemoval": @@ @@ $json = json_encode(['methods' => $methods], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - return ($json === false ? '{"methods":{}}' : $json) . "\n"; + return $json === false ? '{"methods":{}}' : $json; } private function renderForward(ForwardSite $forward): string

Check warning on line 110 in src/Console/Application.php

View workflow job for this annotation

GitHub Actions / Mutation (changed files)

Escaped Mutant for Mutator "Concat": @@ @@ $json = json_encode(['methods' => $methods], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - return ($json === false ? '{"methods":{}}' : $json) . "\n"; + return "\n" . $json === false ? '{"methods":{}}' : $json; } private function renderForward(ForwardSite $forward): string
}

private function renderForward(ForwardSite $forward): string
{
return $forward->callee->kind->value . ':' . $forward->callee->name . '@' . $forward->argKey;
}

private static function helpText(): string
{
return <<<'TXT'
Expand All @@ -69,7 +135,7 @@
Reporting:
--limit <n> Fail on chains with >= n pass-through hops (default: 3)
--warn-limit <n> Warn (do not fail CI) on chains with >= n hops
--format <fmt> text|json|github|checkstyle|sarif (default: text)
--format <fmt> text|json|github|checkstyle|sarif|summary (default: text)
--explain Show why chains ended (call resolution trace)

Diff-aware mode:
Expand All @@ -80,6 +146,9 @@
--baseline <file> Ignore findings recorded in the baseline file
--generate-baseline <file> Write current findings to a baseline file

Debugging:
--dump-index Print the classified method index as JSON and exit

Misc:
-h, --help Show this help
-V, --version Show version
Expand All @@ -89,7 +158,8 @@
1 at least one finding at or over --limit
2 tool error (bad arguments, parse failure, ...)

Status: Phase 0 scaffold - analysis is not implemented yet. See docs/plan.md.
Status: Phase 1 - classifier and --dump-index shipped; chain analysis
(default reporting) lands in Phase 2. See docs/plan.md.

TXT;
}
Expand Down
Loading
Loading