From 5d37d2f37a402e49fc99df93b2a06a7411d50ba3 Mon Sep 17 00:00:00 2001 From: Lars Pohlmann Date: Tue, 11 Aug 2026 08:11:05 +0200 Subject: [PATCH 01/10] feat: argv parser and options object --- src/Console/ArgvParser.php | 188 +++++++++++++++++++++++++++ src/Console/InvalidArgsException.php | 9 ++ src/Console/Options.php | 37 ++++++ tests/Console/ArgvParserTest.php | 82 ++++++++++++ 4 files changed, 316 insertions(+) create mode 100644 src/Console/ArgvParser.php create mode 100644 src/Console/InvalidArgsException.php create mode 100644 src/Console/Options.php create mode 100644 tests/Console/ArgvParserTest.php diff --git a/src/Console/ArgvParser.php b/src/Console/ArgvParser.php new file mode 100644 index 0000000..54d3af0 --- /dev/null +++ b/src/Console/ArgvParser.php @@ -0,0 +1,188 @@ + */ + private array $folders = []; + /** @var list */ + private array $files = []; + private int $limit = 3; + private ?int $warnLimit = null; + private string $format = 'text'; + private bool $explain = false; + private bool $changedOnly = false; + private string $gitBase = 'origin/main'; + private ?string $baseline = null; + private ?string $generateBaseline = null; + private bool $dumpIndex = false; + private bool $help = false; + private bool $version = false; + + /** + * @param list $args + */ + public function parse(array $args): Options + { + $this->reset(); + + $index = 0; + $count = count($args); + while ($index < $count) { + $index = $this->consume($args, $index, $count); + } + + return new Options( + folders: $this->folders, + files: $this->files, + limit: $this->limit, + warnLimit: $this->warnLimit, + format: $this->format, + explain: $this->explain, + changedOnly: $this->changedOnly, + gitBase: $this->gitBase, + baseline: $this->baseline, + generateBaseline: $this->generateBaseline, + dumpIndex: $this->dumpIndex, + help: $this->help, + version: $this->version, + ); + } + + private function reset(): void + { + $this->folders = []; + $this->files = []; + $this->limit = 3; + $this->warnLimit = null; + $this->format = 'text'; + $this->explain = false; + $this->changedOnly = false; + $this->gitBase = 'origin/main'; + $this->baseline = null; + $this->generateBaseline = null; + $this->dumpIndex = false; + $this->help = false; + $this->version = false; + } + + /** + * @param list $args + * @return int the next index to read + */ + private function consume(array $args, int $index, int $count): int + { + $raw = $args[$index]; + + $eq = strpos($raw, '='); + if ($eq !== false) { + $this->applyValueFlag(substr($raw, 0, $eq), substr($raw, $eq + 1)); + + return $index + 1; + } + + if (in_array($raw, self::BOOL_FLAGS, true)) { + $this->applyBoolFlag($raw); + + return $index + 1; + } + + if (in_array($raw, self::VALUE_FLAGS, true)) { + if ($index + 1 >= $count) { + throw new InvalidArgsException("missing value for option: {$raw}"); + } + $this->applyValueFlag($raw, $args[$index + 1]); + + return $index + 2; + } + + throw new InvalidArgsException("unknown option: {$raw}"); + } + + private function applyValueFlag(string $name, string $value): void + { + match ($name) { + '--folder' => $this->folders[] = $value, + '--file' => $this->files[] = $value, + '--files' => $this->appendFiles($value), + '--limit' => $this->limit = $this->toInt($name, $value), + '--warn-limit' => $this->warnLimit = $this->toInt($name, $value), + '--format' => $this->format = $this->validateFormat($value), + '--git-base' => $this->gitBase = $value, + '--baseline' => $this->baseline = $value, + '--generate-baseline' => $this->generateBaseline = $value, + default => throw new InvalidArgsException("unknown option: {$name}"), + }; + } + + private function applyBoolFlag(string $name): void + { + match ($name) { + '--explain' => $this->explain = true, + '--changed-only' => $this->changedOnly = true, + '--dump-index' => $this->dumpIndex = true, + '--help', '-h' => $this->help = true, + '--version', '-V' => $this->version = true, + default => throw new InvalidArgsException("unknown option: {$name}"), + }; + } + + private function appendFiles(string $value): void + { + foreach (explode(',', $value) as $file) { + $this->files[] = $file; + } + } + + private function toInt(string $flag, string $value): int + { + if ($value === '' || ! ctype_digit($value)) { + throw new InvalidArgsException("option {$flag} expects an integer, got: {$value}"); + } + + return (int) $value; + } + + private function validateFormat(string $value): string + { + if (! in_array($value, self::VALID_FORMATS, true)) { + throw new InvalidArgsException( + "unknown format: {$value} (expected " . implode('|', self::VALID_FORMATS) . ')' + ); + } + + return $value; + } +} diff --git a/src/Console/InvalidArgsException.php b/src/Console/InvalidArgsException.php new file mode 100644 index 0000000..5a8f594 --- /dev/null +++ b/src/Console/InvalidArgsException.php @@ -0,0 +1,9 @@ + $folders + * @param list $files + */ + public function __construct( + public readonly array $folders = [], + public readonly array $files = [], + public readonly int $limit = 3, + public readonly ?int $warnLimit = null, + public readonly string $format = 'text', + public readonly bool $explain = false, + public readonly bool $changedOnly = false, + public readonly string $gitBase = 'origin/main', + public readonly ?string $baseline = null, + public readonly ?string $generateBaseline = null, + public readonly bool $dumpIndex = false, + public readonly bool $help = false, + public readonly bool $version = false, + ) { + } +} diff --git a/tests/Console/ArgvParserTest.php b/tests/Console/ArgvParserTest.php new file mode 100644 index 0000000..43941a7 --- /dev/null +++ b/tests/Console/ArgvParserTest.php @@ -0,0 +1,82 @@ +parse(array_values($args)); + } + + public function testDefaults(): void + { + $o = $this->parse(); + self::assertSame([], $o->folders); + self::assertSame([], $o->files); + self::assertSame(3, $o->limit); + self::assertNull($o->warnLimit); + self::assertSame('text', $o->format); + self::assertFalse($o->explain); + self::assertFalse($o->changedOnly); + self::assertSame('origin/main', $o->gitBase); + self::assertNull($o->baseline); + self::assertFalse($o->dumpIndex); + } + + public function testFolderAccumulates(): void + { + self::assertSame(['a', 'b'], $this->parse('--folder', 'a', '--folder', 'b')->folders); + } + + public function testFilesSplitsOnComma(): void + { + self::assertSame(['a.php', 'b.php'], $this->parse('--files', 'a.php,b.php')->files); + } + + public function testFileAppendsToFiles(): void + { + self::assertSame(['a.php', 'b.php'], $this->parse('--file', 'a.php', '--file', 'b.php')->files); + } + + public function testEqualsSyntaxWorks(): void + { + self::assertSame(5, $this->parse('--limit=5')->limit); + } + + public function testLimitAsSeparateArg(): void + { + self::assertSame(7, $this->parse('--limit', '7')->limit); + } + + public function testNonNumericLimitThrows(): void + { + $this->expectException(InvalidArgsException::class); + $this->parse('--limit', 'x'); + } + + public function testMissingValueThrows(): void + { + $this->expectException(InvalidArgsException::class); + $this->parse('--folder'); + } + + public function testUnknownFlagThrows(): void + { + $this->expectException(InvalidArgsException::class); + $this->parse('--nope'); + } + + public function testUnknownFormatThrows(): void + { + $this->expectException(InvalidArgsException::class); + $this->parse('--format', 'xml'); + } +} From a02e7ed37bbb99373604ec73c501406c9b5d84ff Mon Sep 17 00:00:00 2001 From: Lars Pohlmann Date: Tue, 11 Aug 2026 08:12:45 +0200 Subject: [PATCH 02/10] feat: file discovery --- src/Discovery/FileLocator.php | 70 +++++++++++++++++++ tests/Discovery/FileLocatorTest.php | 102 ++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 src/Discovery/FileLocator.php create mode 100644 tests/Discovery/FileLocatorTest.php diff --git a/src/Discovery/FileLocator.php b/src/Discovery/FileLocator.php new file mode 100644 index 0000000..20dff75 --- /dev/null +++ b/src/Discovery/FileLocator.php @@ -0,0 +1,70 @@ + + */ + public function locate(Options $options): array + { + /** @var array $found */ + $found = []; + + foreach ($options->folders as $folder) { + $this->collectFolder($folder, $found); + } + + foreach ($options->files as $file) { + $real = realpath($file); + if ($real === false || ! is_file($real)) { + throw new InvalidArgsException("file not found: {$file}"); + } + $found[$real] = true; + } + + $paths = array_keys($found); + sort($paths); + + return $paths; + } + + /** + * @param array $found + * @param-out array $found + */ + private function collectFolder(string $folder, array &$found): void + { + $real = realpath($folder); + if ($real === false || ! is_dir($real)) { + throw new InvalidArgsException("folder not found: {$folder}"); + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($real, \FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $fileInfo) { + if (! $fileInfo instanceof \SplFileInfo || ! $fileInfo->isFile()) { + continue; + } + if (strtolower($fileInfo->getExtension()) !== 'php') { + continue; + } + $path = $fileInfo->getRealPath(); + if ($path !== false) { + $found[$path] = true; + } + } + } +} diff --git a/tests/Discovery/FileLocatorTest.php b/tests/Discovery/FileLocatorTest.php new file mode 100644 index 0000000..9f4d6c1 --- /dev/null +++ b/tests/Discovery/FileLocatorTest.php @@ -0,0 +1,102 @@ +root = $base; + } + + protected function tearDown(): void + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->root, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + $path = (string) $item; + is_dir($path) ? rmdir($path) : unlink($path); + } + rmdir($this->root); + } + + /** + * @return list + */ + private function locate(Options $options): array + { + return (new FileLocator())->locate($options); + } + + private function real(string $relative): string + { + $path = realpath($this->root . '/' . $relative); + self::assertIsString($path); + + return $path; + } + + public function testRecursesFoldersAndKeepsOnlyPhpFiles(): void + { + $found = $this->locate(new Options(folders: [$this->root])); + + self::assertSame( + [$this->real('a.php'), $this->real('sub/b.php'), $this->real('sub/deep/c.php')], + $found + ); + } + + public function testResultIsSortedAndRealpathNormalized(): void + { + $found = $this->locate(new Options(folders: [$this->root . '/sub/..'])); + + $sorted = $found; + sort($sorted); + self::assertSame($sorted, $found); + foreach ($found as $path) { + self::assertSame(realpath($path), $path); + } + } + + public function testExplicitFilesAreIncludedAndDeduplicated(): void + { + $found = $this->locate(new Options( + folders: [$this->root], + files: [$this->root . '/a.php'], + )); + + self::assertSame( + [$this->real('a.php'), $this->real('sub/b.php'), $this->real('sub/deep/c.php')], + $found + ); + } + + public function testMissingFolderThrows(): void + { + $this->expectException(InvalidArgsException::class); + $this->locate(new Options(folders: [$this->root . '/does-not-exist'])); + } + + public function testMissingFileThrows(): void + { + $this->expectException(InvalidArgsException::class); + $this->locate(new Options(files: [$this->root . '/ghost.php'])); + } +} From 3b6900cd9eb216a4075d84ca156d77d57f57bff2 Mon Sep 17 00:00:00 2001 From: Lars Pohlmann Date: Tue, 11 Aug 2026 08:21:39 +0200 Subject: [PATCH 03/10] feat: method index Parse located files into a whole-project MethodIndex via php-parser (NameResolver + ParentConnectingVisitor). Records FQMNs (Ns\Class::method for methods, Ns\func for functions), structural param facts (name, position, by-ref, variadic, declared type), and per-class hierarchy facts (parent, interfaces, traits). Usage classification (fate/forwards) lands in Task 1.4; params are placeholder-classified Unused for now. Beyond the plan's named file list this adds three internal helpers the typed design needs: CalleeKind enum, ClassInfo DTO for the class-facts, IndexingVisitor, and a ParseException for the exit-2 parse-error path. --- src/Index/CalleeKind.php | 17 +++ src/Index/CalleeRef.php | 25 ++++ src/Index/ClassInfo.php | 25 ++++ src/Index/ForwardSite.php | 19 +++ src/Index/Indexer.php | 76 ++++++++++++ src/Index/IndexingVisitor.php | 217 ++++++++++++++++++++++++++++++++++ src/Index/MethodIndex.php | 40 +++++++ src/Index/MethodInfo.php | 24 ++++ src/Index/ParamFate.php | 21 ++++ src/Index/ParamInfo.php | 25 ++++ src/Index/ParseException.php | 13 ++ tests/Index/IndexerTest.php | 137 +++++++++++++++++++++ 12 files changed, 639 insertions(+) create mode 100644 src/Index/CalleeKind.php create mode 100644 src/Index/CalleeRef.php create mode 100644 src/Index/ClassInfo.php create mode 100644 src/Index/ForwardSite.php create mode 100644 src/Index/Indexer.php create mode 100644 src/Index/IndexingVisitor.php create mode 100644 src/Index/MethodIndex.php create mode 100644 src/Index/MethodInfo.php create mode 100644 src/Index/ParamFate.php create mode 100644 src/Index/ParamInfo.php create mode 100644 src/Index/ParseException.php create mode 100644 tests/Index/IndexerTest.php diff --git a/src/Index/CalleeKind.php b/src/Index/CalleeKind.php new file mode 100644 index 0000000..9835ca1 --- /dev/null +++ b/src/Index/CalleeKind.php @@ -0,0 +1,17 @@ + $interfaces implemented interfaces / extended parent interfaces + * @param list $traits used traits + */ + public function __construct( + public readonly string $name, + public readonly ?string $parent, + public readonly array $interfaces, + public readonly array $traits, + ) { + } +} diff --git a/src/Index/ForwardSite.php b/src/Index/ForwardSite.php new file mode 100644 index 0000000..5acb218 --- /dev/null +++ b/src/Index/ForwardSite.php @@ -0,0 +1,19 @@ + $files + * + * @throws ParseException if any file cannot be read or parsed + */ + public function index(array $files): MethodIndex + { + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + + $visitor = new IndexingVisitor(); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new NameResolver()); + $traverser->addVisitor(new ParentConnectingVisitor()); + $traverser->addVisitor($visitor); + + $errors = []; + foreach ($files as $file) { + $error = $this->indexFile($parser, $traverser, $visitor, $file); + if ($error !== null) { + $errors[] = $error; + } + } + + if ($errors !== []) { + throw new ParseException("parse errors:\n" . implode("\n", $errors)); + } + + return new MethodIndex($visitor->methods(), $visitor->classes()); + } + + private function indexFile( + Parser $parser, + NodeTraverser $traverser, + IndexingVisitor $visitor, + string $file + ): ?string { + $code = @file_get_contents($file); + if ($code === false) { + return "{$file}: could not read file"; + } + + try { + $ast = $parser->parse($code); + } catch (Error $e) { + return "{$file}: {$e->getMessage()}"; + } + + if ($ast === null) { + return "{$file}: could not parse file"; + } + + $visitor->setFile($file); + $traverser->traverse($ast); + + return null; + } +} diff --git a/src/Index/IndexingVisitor.php b/src/Index/IndexingVisitor.php new file mode 100644 index 0000000..5f42f3a --- /dev/null +++ b/src/Index/IndexingVisitor.php @@ -0,0 +1,217 @@ +, traits: list} + */ +final class IndexingVisitor extends NodeVisitorAbstract +{ + /** @var array */ + private array $methods = []; + + /** @var array */ + private array $classes = []; + + private string $file = ''; + + public function setFile(string $file): void + { + $this->file = $file; + } + + public function enterNode(Node $node): ?Node + { + if ($node instanceof ClassLike) { + $this->recordClass($node); + } elseif ($node instanceof TraitUse) { + $this->recordTraitUse($node); + } elseif ($node instanceof ClassMethod) { + $this->recordMethod($node); + } elseif ($node instanceof Function_) { + $this->recordFunction($node); + } + + return null; + } + + /** + * @return array + */ + public function methods(): array + { + return $this->methods; + } + + /** + * @return array + */ + public function classes(): array + { + $out = []; + foreach ($this->classes as $fqcn => $facts) { + $out[$fqcn] = new ClassInfo( + $fqcn, + $facts['parent'], + $facts['interfaces'], + $facts['traits'], + ); + } + + return $out; + } + + private function recordClass(ClassLike $node): void + { + $fqcn = $node->namespacedName?->toString(); + if ($fqcn === null) { + return; + } + + $parent = null; + $interfaces = []; + + if ($node instanceof Class_) { + $parent = $node->extends?->toString(); + $interfaces = $this->namesToStrings($node->implements); + } elseif ($node instanceof Interface_) { + $interfaces = $this->namesToStrings($node->extends); + } elseif ($node instanceof Enum_) { + $interfaces = $this->namesToStrings($node->implements); + } + + $this->classes[$fqcn] = [ + 'parent' => $parent, + 'interfaces' => $interfaces, + 'traits' => [], + ]; + } + + private function recordTraitUse(TraitUse $node): void + { + $fqcn = $this->enclosingClassName($node); + if ($fqcn === null || ! isset($this->classes[$fqcn])) { + return; + } + + foreach ($node->traits as $trait) { + $this->classes[$fqcn]['traits'][] = $trait->toString(); + } + } + + private function recordMethod(ClassMethod $node): void + { + $fqcn = $this->enclosingClassName($node); + if ($fqcn === null) { + return; + } + + $fqmn = $fqcn . '::' . $node->name->toString(); + $this->methods[$fqmn] = new MethodInfo( + $fqmn, + $this->file, + $node->getStartLine(), + $this->buildParams($node->params), + $fqcn, + ); + } + + private function recordFunction(Function_ $node): void + { + $fqmn = $node->namespacedName?->toString(); + if ($fqmn === null) { + return; + } + + $this->methods[$fqmn] = new MethodInfo( + $fqmn, + $this->file, + $node->getStartLine(), + $this->buildParams($node->params), + null, + ); + } + + private function enclosingClassName(Node $node): ?string + { + $parent = $node->getAttribute('parent'); + if (! $parent instanceof ClassLike) { + return null; + } + + return $parent->namespacedName?->toString(); + } + + /** + * @param array $params + * @return list + */ + private function buildParams(array $params): array + { + $out = []; + foreach ($params as $position => $param) { + $var = $param->var; + if (! $var instanceof Node\Expr\Variable || ! is_string($var->name)) { + continue; + } + + $out[] = new ParamInfo( + name: $var->name, + position: $position, + fate: ParamFate::Unused, + forwards: [], + byRef: $param->byRef, + variadic: $param->variadic, + type: $this->typeToString($param->type), + ); + } + + return $out; + } + + private function typeToString(?Node $type): ?string + { + if ($type instanceof Name || $type instanceof Identifier) { + return $type->toString(); + } + if ($type instanceof NullableType) { + return $this->typeToString($type->type); + } + + return null; + } + + /** + * @param array $names + * @return list + */ + private function namesToStrings(array $names): array + { + $out = []; + foreach ($names as $name) { + $out[] = $name->toString(); + } + + return $out; + } +} diff --git a/src/Index/MethodIndex.php b/src/Index/MethodIndex.php new file mode 100644 index 0000000..26f8a28 --- /dev/null +++ b/src/Index/MethodIndex.php @@ -0,0 +1,40 @@ + MethodInfo, plus + * per-class hierarchy facts. Immutable; produced by {@see Indexer}. + */ +final class MethodIndex +{ + /** + * @param array $methods + * @param array $classes + */ + public function __construct( + private readonly array $methods, + private readonly array $classes = [], + ) { + } + + public function get(string $fqmn): ?MethodInfo + { + return $this->methods[$fqmn] ?? null; + } + + /** + * @return iterable + */ + public function all(): iterable + { + return $this->methods; + } + + public function classInfo(string $fqcn): ?ClassInfo + { + return $this->classes[$fqcn] ?? null; + } +} diff --git a/src/Index/MethodInfo.php b/src/Index/MethodInfo.php new file mode 100644 index 0000000..f171711 --- /dev/null +++ b/src/Index/MethodInfo.php @@ -0,0 +1,24 @@ + $params + */ + public function __construct( + public readonly string $fqmn, + public readonly string $file, + public readonly int $line, + public readonly array $params, + public readonly ?string $class = null, + ) { + } +} diff --git a/src/Index/ParamFate.php b/src/Index/ParamFate.php new file mode 100644 index 0000000..fb81948 --- /dev/null +++ b/src/Index/ParamFate.php @@ -0,0 +1,21 @@ + $forwards + */ + public function __construct( + public readonly string $name, + public readonly int $position, + public readonly ParamFate $fate, + public readonly array $forwards = [], + public readonly bool $byRef = false, + public readonly bool $variadic = false, + public readonly ?string $type = null, + ) { + } +} diff --git a/src/Index/ParseException.php b/src/Index/ParseException.php new file mode 100644 index 0000000..b35a744 --- /dev/null +++ b/src/Index/ParseException.php @@ -0,0 +1,13 @@ +file !== '' && is_file($this->file)) { + unlink($this->file); + } + } + + 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 testIndexesMethodsFunctionsAndTraitMethods(): void + { + $index = $this->index(self::SAMPLE); + + self::assertNotNull($index->get('Demo\Service::handle')); + self::assertNotNull($index->get('Demo\helper')); + self::assertNotNull($index->get('Demo\Logs::log')); + self::assertNull($index->get('Demo\Service::missing')); + } + + public function testFqmnUsesDoubleColonForMethodsAndBareNameForFunctions(): void + { + $index = $this->index(self::SAMPLE); + + $handle = $index->get('Demo\Service::handle'); + $helper = $index->get('Demo\helper'); + self::assertNotNull($handle); + self::assertNotNull($helper); + self::assertSame('Demo\Service::handle', $handle->fqmn); + self::assertSame('Demo\helper', $helper->fqmn); + self::assertSame('Demo\Service', $handle->class); + self::assertNull($helper->class); + } + + public function testParamNamesPositionsAndTypes(): void + { + $handle = $this->index(self::SAMPLE)->get('Demo\Service::handle'); + self::assertNotNull($handle); + + self::assertSame('config', $handle->params[0]->name); + self::assertSame(0, $handle->params[0]->position); + self::assertSame('Demo\Cfg', $handle->params[0]->type); + + self::assertSame('count', $handle->params[1]->name); + self::assertSame(1, $handle->params[1]->position); + self::assertSame('int', $handle->params[1]->type); + } + + public function testByRefFlag(): void + { + $mutate = $this->index(self::SAMPLE)->get('Demo\Service::mutate'); + self::assertNotNull($mutate); + self::assertTrue($mutate->params[0]->byRef); + self::assertFalse($mutate->params[0]->variadic); + } + + public function testVariadicFlag(): void + { + $collect = $this->index(self::SAMPLE)->get('Demo\Service::collect'); + self::assertNotNull($collect); + self::assertTrue($collect->params[0]->variadic); + self::assertFalse($collect->params[0]->byRef); + } + + public function testRecordsClassHierarchyFacts(): void + { + $service = $this->index(self::SAMPLE)->classInfo('Demo\Service'); + self::assertNotNull($service); + self::assertSame('Demo\Base', $service->parent); + self::assertSame(['Demo\Handler'], $service->interfaces); + self::assertSame(['Demo\Logs'], $service->traits); + } + + public function testParseErrorThrowsParseException(): void + { + $this->expectException(ParseException::class); + $this->index(" Date: Tue, 11 Aug 2026 08:29:47 +0200 Subject: [PATCH 04/10] feat: parameter usage classifier Classify each parameter as PureForward / Used / ByRefTerminated / Unused per the frozen core semantics: pure whole-argument forwarding is a hop, any other occurrence (property/method access, assignment, return, interpolation, closure use() or arrow-fn capture) is a terminal use. By-ref receipt and promoted/stored constructor properties terminate. Variadic spread forwarding stays a hop; non-variadic spread is a conservative use. Named and positional forward arg keys are recorded, and forward sites are kept even on Used params (Phase 4 needs the call-site lines). Classification is deferred to after the whole-project traversal so call targets inside bodies are already fully qualified by NameResolver; the IndexingVisitor now collects pending nodes and the Indexer classifies them. Adds an internal ForwardCollector scope-aware visitor. --- src/Index/ForwardCollector.php | 177 ++++++++++++++++++++++++++++ src/Index/Indexer.php | 28 ++++- src/Index/IndexingVisitor.php | 93 +++++---------- src/Index/UsageClassifier.php | 98 +++++++++++++++ tests/Index/UsageClassifierTest.php | 158 +++++++++++++++++++++++++ 5 files changed, 488 insertions(+), 66 deletions(-) create mode 100644 src/Index/ForwardCollector.php create mode 100644 src/Index/UsageClassifier.php create mode 100644 tests/Index/UsageClassifierTest.php diff --git a/src/Index/ForwardCollector.php b/src/Index/ForwardCollector.php new file mode 100644 index 0000000..185f375 --- /dev/null +++ b/src/Index/ForwardCollector.php @@ -0,0 +1,177 @@ + */ + public array $forwards = []; + + public function __construct( + private readonly string $name, + private readonly bool $variadic, + ) { + } + + public function enterNode(Node $node): ?int + { + if ($node instanceof Closure) { + $this->handleClosure($node); + + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + if ($node instanceof ArrowFunction) { + $this->handleArrowFunction($node); + + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + if ($node instanceof ClassLike || $node instanceof Function_) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + if ($node instanceof Variable && $node->name === $this->name) { + $this->handleVariable($node); + } + + return null; + } + + private function handleClosure(Closure $node): void + { + foreach ($node->uses as $use) { + if ($use->var->name === $this->name) { + $this->used = true; + } + } + } + + private function handleArrowFunction(ArrowFunction $node): void + { + $hit = (new NodeFinder())->findFirst( + $node->expr, + fn (Node $n): bool => $n instanceof Variable && $n->name === $this->name, + ); + if ($hit !== null) { + $this->used = true; + } + } + + private function handleVariable(Variable $node): void + { + $forward = $this->asForward($node); + if ($forward === null) { + $this->used = true; + + return; + } + + $this->forwards[] = $forward; + } + + private function asForward(Variable $var): ?ForwardSite + { + $arg = $var->getAttribute('parent'); + if (! $arg instanceof Arg || $arg->value !== $var) { + return null; + } + if ($arg->unpack && ! $this->variadic) { + return null; + } + + $call = $arg->getAttribute('parent'); + if (! $call instanceof CallLike) { + return null; + } + + $callee = $this->calleeRef($call); + if ($callee === null) { + return null; + } + + return new ForwardSite($callee, $this->argKey($arg, $call), $var->getStartLine()); + } + + private function calleeRef(CallLike $call): ?CalleeRef + { + return match (true) { + $call instanceof FuncCall => $call->name instanceof Name + ? new CalleeRef(CalleeKind::Func, $call->name->toString()) + : null, + $call instanceof New_ => $call->class instanceof Name + ? new CalleeRef(CalleeKind::Instantiation, $call->class->toString()) + : null, + $call instanceof StaticCall => $call->class instanceof Name && $call->name instanceof Identifier + ? new CalleeRef(CalleeKind::StaticCall, $call->name->toString(), $call->class->toString()) + : null, + $call instanceof MethodCall || $call instanceof NullsafeMethodCall => $call->name instanceof Identifier + ? new CalleeRef(CalleeKind::Method, $call->name->toString(), $this->receiverHint($call->var)) + : null, + default => null, + }; + } + + private function receiverHint(Expr $receiver): string + { + if ($receiver instanceof Variable && is_string($receiver->name)) { + return $receiver->name; + } + if ($receiver instanceof New_ && $receiver->class instanceof Name) { + return $receiver->class->toString(); + } + + return 'raw'; + } + + /** + * @return int|string positional index, or the name for a named argument + */ + private function argKey(Arg $arg, CallLike $call): int|string + { + if ($arg->name !== null) { + return $arg->name->toString(); + } + + $position = 0; + foreach ($call->getRawArgs() as $index => $candidate) { + if ($candidate === $arg) { + $position = $index; + break; + } + } + + return $position; + } +} diff --git a/src/Index/Indexer.php b/src/Index/Indexer.php index bb12e86..dc81e20 100644 --- a/src/Index/Indexer.php +++ b/src/Index/Indexer.php @@ -14,6 +14,8 @@ /** * Parses the located files into a whole-project {@see MethodIndex}. Parameter * usage classification is delegated to {@see UsageClassifier}. + * + * @phpstan-import-type PendingMethod from IndexingVisitor */ final class Indexer { @@ -44,7 +46,31 @@ public function index(array $files): MethodIndex throw new ParseException("parse errors:\n" . implode("\n", $errors)); } - return new MethodIndex($visitor->methods(), $visitor->classes()); + return new MethodIndex($this->classifyPending($visitor->pending()), $visitor->classes()); + } + + /** + * @param list $pending + * + * @return array + */ + private function classifyPending(array $pending): array + { + $classifier = new UsageClassifier(); + $methods = []; + foreach ($pending as $entry) { + $node = $entry['node']; + $params = $classifier->classify($node->getParams(), $node->getStmts()); + $methods[$entry['fqmn']] = new MethodInfo( + $entry['fqmn'], + $entry['file'], + $entry['line'], + $params, + $entry['class'], + ); + } + + return $methods; } private function indexFile( diff --git a/src/Index/IndexingVisitor.php b/src/Index/IndexingVisitor.php index 5f42f3a..93dd824 100644 --- a/src/Index/IndexingVisitor.php +++ b/src/Index/IndexingVisitor.php @@ -5,10 +5,8 @@ namespace PhpTramp\Index; use PhpParser\Node; -use PhpParser\Node\Identifier; +use PhpParser\Node\FunctionLike; use PhpParser\Node\Name; -use PhpParser\Node\NullableType; -use PhpParser\Node\Param; use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassLike; use PhpParser\Node\Stmt\ClassMethod; @@ -19,16 +17,21 @@ use PhpParser\NodeVisitorAbstract; /** - * Accumulates methods, functions, and class-hierarchy facts across every file - * traversed. Relies on NameResolver (FQ names) and ParentConnectingVisitor - * (the `parent` attribute) running before it in the traverser. + * Accumulates class-hierarchy facts and a list of pending function/method + * nodes across every file traversed. Parameter classification is deferred to + * the Indexer once the whole traversal (including NameResolver) has run, so + * that call targets inside bodies are already fully qualified. + * + * 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 PendingMethod array{fqmn: string, file: string, line: int, class: ?string, node: FunctionLike} */ final class IndexingVisitor extends NodeVisitorAbstract { - /** @var array */ - private array $methods = []; + /** @var list */ + private array $pending = []; /** @var array */ private array $classes = []; @@ -56,11 +59,11 @@ public function enterNode(Node $node): ?Node } /** - * @return array + * @return list */ - public function methods(): array + public function pending(): array { - return $this->methods; + return $this->pending; } /** @@ -126,14 +129,13 @@ private function recordMethod(ClassMethod $node): void return; } - $fqmn = $fqcn . '::' . $node->name->toString(); - $this->methods[$fqmn] = new MethodInfo( - $fqmn, - $this->file, - $node->getStartLine(), - $this->buildParams($node->params), - $fqcn, - ); + $this->pending[] = [ + 'fqmn' => $fqcn . '::' . $node->name->toString(), + 'file' => $this->file, + 'line' => $node->getStartLine(), + 'class' => $fqcn, + 'node' => $node, + ]; } private function recordFunction(Function_ $node): void @@ -143,13 +145,13 @@ private function recordFunction(Function_ $node): void return; } - $this->methods[$fqmn] = new MethodInfo( - $fqmn, - $this->file, - $node->getStartLine(), - $this->buildParams($node->params), - null, - ); + $this->pending[] = [ + 'fqmn' => $fqmn, + 'file' => $this->file, + 'line' => $node->getStartLine(), + 'class' => null, + 'node' => $node, + ]; } private function enclosingClassName(Node $node): ?string @@ -162,45 +164,6 @@ private function enclosingClassName(Node $node): ?string return $parent->namespacedName?->toString(); } - /** - * @param array $params - * @return list - */ - private function buildParams(array $params): array - { - $out = []; - foreach ($params as $position => $param) { - $var = $param->var; - if (! $var instanceof Node\Expr\Variable || ! is_string($var->name)) { - continue; - } - - $out[] = new ParamInfo( - name: $var->name, - position: $position, - fate: ParamFate::Unused, - forwards: [], - byRef: $param->byRef, - variadic: $param->variadic, - type: $this->typeToString($param->type), - ); - } - - return $out; - } - - private function typeToString(?Node $type): ?string - { - if ($type instanceof Name || $type instanceof Identifier) { - return $type->toString(); - } - if ($type instanceof NullableType) { - return $this->typeToString($type->type); - } - - return null; - } - /** * @param array $names * @return list diff --git a/src/Index/UsageClassifier.php b/src/Index/UsageClassifier.php new file mode 100644 index 0000000..8921707 --- /dev/null +++ b/src/Index/UsageClassifier.php @@ -0,0 +1,98 @@ + $params + * @param array|null $stmts null for abstract / interface methods + * + * @return list + */ + public function classify(array $params, ?array $stmts): array + { + $out = []; + foreach ($params as $position => $param) { + $out[] = $this->classifyParam($param, $position, $stmts); + } + + return $out; + } + + /** + * @param array|null $stmts + */ + private function classifyParam(Param $param, int $position, ?array $stmts): ParamInfo + { + $name = ($param->var instanceof Variable && is_string($param->var->name)) ? $param->var->name : ''; + $type = $this->typeToString($param->type); + + if ($param->byRef) { + return new ParamInfo($name, $position, ParamFate::ByRefTerminated, [], true, $param->variadic, $type); + } + + if ($param->isPromoted()) { + return new ParamInfo($name, $position, ParamFate::Used, [], false, $param->variadic, $type); + } + + if ($stmts === null || $name === '') { + return new ParamInfo($name, $position, ParamFate::Unused, [], false, $param->variadic, $type); + } + + [$fate, $forwards] = $this->analyzeBody($name, $param->variadic, $stmts); + + return new ParamInfo($name, $position, $fate, $forwards, false, $param->variadic, $type); + } + + /** + * @param array $stmts + * + * @return array{0: ParamFate, 1: list} + */ + private function analyzeBody(string $name, bool $variadic, array $stmts): array + { + $collector = new ForwardCollector($name, $variadic); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ParentConnectingVisitor()); + $traverser->addVisitor($collector); + $traverser->traverse($stmts); + + if ($collector->used) { + return [ParamFate::Used, $collector->forwards]; + } + if ($collector->forwards === []) { + return [ParamFate::Unused, []]; + } + + return [ParamFate::PureForward, $collector->forwards]; + } + + private function typeToString(?Node $type): ?string + { + if ($type instanceof Name || $type instanceof Identifier) { + return $type->toString(); + } + if ($type instanceof NullableType) { + return $this->typeToString($type->type); + } + + return null; + } +} diff --git a/tests/Index/UsageClassifierTest.php b/tests/Index/UsageClassifierTest.php new file mode 100644 index 0000000..0768814 --- /dev/null +++ b/tests/Index/UsageClassifierTest.php @@ -0,0 +1,158 @@ + keyed by param name, for method C::m */ + private function classify(string $body, string $params = 'Cfg $p'): array + { + $code = "index([$file])->get('C::m'); + self::assertNotNull($method); + $byName = []; + foreach ($method->params as $param) { + $byName[$param->name] = $param; + } + + return $byName; + } finally { + unlink($file); + } + } + + public function testPureSingleForwardIsPureForward(): void + { + $p = $this->classify('other($p);')['p']; + self::assertSame(ParamFate::PureForward, $p->fate); + self::assertCount(1, $p->forwards); + } + + public function testForwardPlusPropertyReadIsUsed(): void + { + self::assertSame(ParamFate::Used, $this->classify('log($p->env); other($p);')['p']->fate); + } + + public function testMethodCallOnParamIsUsed(): void + { + self::assertSame(ParamFate::Used, $this->classify('$p->run();')['p']->fate); + } + + public function testAssignmentFromParamIsUsed(): void + { + self::assertSame(ParamFate::Used, $this->classify('$x = $p; other($p);')['p']->fate); + } + + public function testReassignmentOfParamIsUsed(): void + { + self::assertSame(ParamFate::Used, $this->classify('$p = null; other($p);')['p']->fate); + } + + public function testReturnIsUsed(): void + { + self::assertSame(ParamFate::Used, $this->classify('return $p;')['p']->fate); + } + + public function testStringInterpolationIsUsed(): void + { + self::assertSame(ParamFate::Used, $this->classify('echo "x{$p}";')['p']->fate); + } + + public function testClosureCaptureIsUsed(): void + { + self::assertSame(ParamFate::Used, $this->classify('$f = function () use ($p) { other($p); };')['p']->fate); + } + + public function testArrowFnBodyReferenceIsUsed(): void + { + self::assertSame(ParamFate::Used, $this->classify('$f = fn () => other($p);')['p']->fate); + } + + public function testByRefParamIsByRefTerminated(): void + { + self::assertSame(ParamFate::ByRefTerminated, $this->classify('other($p);', 'Cfg &$p')['p']->fate); + } + + public function testVariadicSpreadForwardIsPureForward(): void + { + self::assertSame(ParamFate::PureForward, $this->classify('other(...$p);', 'Cfg ...$p')['p']->fate); + } + + public function testNamedArgForwardRecordsArgName(): void + { + $p = $this->classify('other(cfg: $p);')['p']; + self::assertSame(ParamFate::PureForward, $p->fate); + self::assertSame('cfg', $p->forwards[0]->argKey); + } + + public function testPositionalForwardRecordsPosition(): void + { + $p = $this->classify('other(1, $p);')['p']; + self::assertSame(1, $p->forwards[0]->argKey); + } + + public function testTwoForwardsRecordTwoSites(): void + { + $p = $this->classify('a($p); b($p);')['p']; + self::assertSame(ParamFate::PureForward, $p->fate); + self::assertCount(2, $p->forwards); + } + + public function testForwardIntoNestedCallIsForwardToInnerCallee(): void + { + // $p is an argument of wrap(), not of outer(): the edge goes to wrap(). + $p = $this->classify('outer(wrap($p));')['p']; + self::assertSame(ParamFate::PureForward, $p->fate); + self::assertCount(1, $p->forwards); + } + + public function testConstructorArgForwardIsPureForward(): void + { + self::assertSame(ParamFate::PureForward, $this->classify('new Cfg($p);')['p']->fate); + } + + public function testUntouchedParamIsUnused(): void + { + self::assertSame(ParamFate::Unused, $this->classify('log(1);')['p']->fate); + } + + public function testConstructorPropertyStorageIsUsed(): void + { + $code = 'c = $p; } }'; + $file = tempnam(sys_get_temp_dir(), 'phptramp') . '.php'; + file_put_contents($file, $code); + try { + $method = (new Indexer())->index([$file])->get('Mailer::__construct'); + self::assertNotNull($method); + self::assertSame(ParamFate::Used, $method->params[0]->fate); + } finally { + unlink($file); + } + } + + public function testPromotedConstructorPropertyIsUsed(): void + { + $code = 'index([$file])->get('Mailer::__construct'); + self::assertNotNull($method); + self::assertSame(ParamFate::Used, $method->params[0]->fate); + } finally { + unlink($file); + } + } +} From 43a70a6832f6e2f47d554e2e941ac8f1544b3ed3 Mon Sep 17 00:00:00 2001 From: Lars Pohlmann Date: Tue, 11 Aug 2026 08:33:50 +0200 Subject: [PATCH 05/10] feat: --dump-index and fixture harness Wire the Phase 1 pipeline into Application: parse args -> locate files -> index/classify -> render the classified method index as JSON under --dump-index (methods with no params omitted; forwards rendered as kind:callee@argKey). Bad args and parse errors report to stderr and exit 2; default chain reporting still returns the Phase 1 not-implemented notice. Refresh the help text to match the real flag surface. Add the data-driven FixtureTest harness: it runs the real CLI over every tests/fixtures//src and compares normalized --dump-index output to the case's expected-index.json, plus the classifier-smoke fixture (the README 3-hop chain Phase 2 will reuse for the first end-to-end finding). --- src/Console/Application.php | 80 +++++++++++++++- tests/FixtureTest.php | 91 +++++++++++++++++++ .../classifier-smoke/expected-index.json | 20 ++++ tests/fixtures/classifier-smoke/src/Demo.php | 41 +++++++++ 4 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 tests/FixtureTest.php create mode 100644 tests/fixtures/classifier-smoke/expected-index.json create mode 100644 tests/fixtures/classifier-smoke/src/Demo.php diff --git a/src/Console/Application.php b/src/Console/Application.php index 7e3e3b4..c057e6e 100644 --- a/src/Console/Application.php +++ b/src/Console/Application.php @@ -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'; @@ -32,23 +39,82 @@ public function run(array $argv): int { $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); + + return ($json === false ? '{"methods":{}}' : $json) . "\n"; + } + + private function renderForward(ForwardSite $forward): string + { + return $forward->callee->kind->value . ':' . $forward->callee->name . '@' . $forward->argKey; + } + private static function helpText(): string { return <<<'TXT' @@ -69,7 +135,7 @@ private static function helpText(): string Reporting: --limit Fail on chains with >= n pass-through hops (default: 3) --warn-limit Warn (do not fail CI) on chains with >= n hops - --format text|json|github|checkstyle|sarif (default: text) + --format text|json|github|checkstyle|sarif|summary (default: text) --explain Show why chains ended (call resolution trace) Diff-aware mode: @@ -80,6 +146,9 @@ private static function helpText(): string --baseline Ignore findings recorded in the baseline file --generate-baseline 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 @@ -89,7 +158,8 @@ private static function helpText(): string 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; } diff --git a/tests/FixtureTest.php b/tests/FixtureTest.php new file mode 100644 index 0000000..deea65b --- /dev/null +++ b/tests/FixtureTest.php @@ -0,0 +1,91 @@ +/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. + */ +final class FixtureTest extends TestCase +{ + /** + * @return iterable + */ + public static function fixtureProvider(): iterable + { + $pattern = __DIR__ . '/fixtures/*/expected-index.json'; + foreach (glob($pattern) ?: [] as $expectedFile) { + $caseDir = dirname($expectedFile); + yield basename($caseDir) => [$caseDir]; + } + } + + #[DataProvider('fixtureProvider')] + public function testDumpIndexMatchesExpectation(string $caseDir): void + { + $real = realpath($caseDir); + self::assertIsString($real); + + $expectedJson = file_get_contents($caseDir . '/expected-index.json'); + self::assertIsString($expectedJson); + $expected = json_decode($expectedJson, true); + + $actual = $this->normalize($this->dumpIndex($caseDir . '/src'), $real); + + self::assertEquals($expected, $actual); + } + + /** + * @return array + */ + private function dumpIndex(string $srcDir): array + { + $stdout = fopen('php://memory', 'w+'); + $stderr = fopen('php://memory', 'w+'); + self::assertIsResource($stdout); + self::assertIsResource($stderr); + + $exit = (new Application($stdout, $stderr))->run(['phptramp', '--folder', $srcDir, '--dump-index']); + self::assertSame(0, $exit); + + rewind($stdout); + $json = stream_get_contents($stdout); + self::assertIsString($json); + $decoded = json_decode($json, true); + self::assertIsArray($decoded); + + return $decoded; + } + + /** + * @param array $decoded + * @return array + */ + private function normalize(array $decoded, string $caseDir): array + { + $prefix = $caseDir . '/'; + $methods = $decoded['methods'] ?? []; + self::assertIsArray($methods); + + foreach ($methods as $fqmn => $method) { + self::assertIsArray($method); + $file = $method['file']; + self::assertIsString($file); + if (str_starts_with($file, $prefix)) { + $method['file'] = substr($file, strlen($prefix)); + } + $methods[$fqmn] = $method; + } + $decoded['methods'] = $methods; + + return $decoded; + } +} diff --git a/tests/fixtures/classifier-smoke/expected-index.json b/tests/fixtures/classifier-smoke/expected-index.json new file mode 100644 index 0000000..cd8891c --- /dev/null +++ b/tests/fixtures/classifier-smoke/expected-index.json @@ -0,0 +1,20 @@ +{ + "methods": { + "Demo\\Controller::handle": { + "file": "src/Demo.php", + "params": [{"name": "config", "fate": "PureForward", "forwards": ["method:process@0"]}] + }, + "Demo\\ServiceA::process": { + "file": "src/Demo.php", + "params": [{"name": "config", "fate": "PureForward", "forwards": ["method:run@0"]}] + }, + "Demo\\ServiceB::run": { + "file": "src/Demo.php", + "params": [{"name": "config", "fate": "PureForward", "forwards": ["new:Demo\\Mailer@0"]}] + }, + "Demo\\Mailer::__construct": { + "file": "src/Demo.php", + "params": [{"name": "config", "fate": "Used", "forwards": []}] + } + } +} diff --git a/tests/fixtures/classifier-smoke/src/Demo.php b/tests/fixtures/classifier-smoke/src/Demo.php new file mode 100644 index 0000000..81a7977 --- /dev/null +++ b/tests/fixtures/classifier-smoke/src/Demo.php @@ -0,0 +1,41 @@ +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 $config; + + public function __construct(Cfg $config) + { + $this->config = $config; + } +} From 333b79cffa102bba363959a49c98690a9e087869 Mon Sep 17 00:00:00 2001 From: Lars Pohlmann Date: Tue, 11 Aug 2026 08:35:01 +0200 Subject: [PATCH 06/10] docs: mark Phase 1 complete in plan and README --- README.md | 4 +++- docs/plan.md | 28 ++++++++++++++-------------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 367a096..79ea9df 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/plan.md b/docs/plan.md index a736b9b..746ca7b 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -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 --dump-index` prints the classified method index as JSON. This makes the classifier — the semantic heart — independently shippable, @@ -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 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 @@ -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. --- From 5698009a12676b106890cf6607413bd9b94399c1 Mon Sep 17 00:00:00 2001 From: Lars Pohlmann Date: Tue, 11 Aug 2026 08:42:20 +0200 Subject: [PATCH 07/10] refactor: simplify parameter classification Two quality cleanups from a review pass, both behavior-preserving: - Connect AST parents once per method body in classify() instead of re-running ParentConnectingVisitor for every parameter; the per-param body walks now share the connected tree. - Collapse the four repeated 7-argument ParamInfo constructions in classifyParam() into a single tail return that computes fate/forwards in a branch. --- src/Index/UsageClassifier.php | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/Index/UsageClassifier.php b/src/Index/UsageClassifier.php index 8921707..c34567b 100644 --- a/src/Index/UsageClassifier.php +++ b/src/Index/UsageClassifier.php @@ -28,6 +28,14 @@ final class UsageClassifier */ public function classify(array $params, ?array $stmts): array { + if ($stmts !== null) { + // Connect parents once for the whole body; every per-parameter walk + // below then reads the same `parent` attributes. + $connector = new NodeTraverser(); + $connector->addVisitor(new ParentConnectingVisitor()); + $connector->traverse($stmts); + } + $out = []; foreach ($params as $position => $param) { $out[] = $this->classifyParam($param, $position, $stmts); @@ -43,22 +51,19 @@ 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 = []; if ($param->byRef) { - return new ParamInfo($name, $position, ParamFate::ByRefTerminated, [], true, $param->variadic, $type); - } - - if ($param->isPromoted()) { - return new ParamInfo($name, $position, ParamFate::Used, [], false, $param->variadic, $type); + $fate = ParamFate::ByRefTerminated; + } elseif ($param->isPromoted()) { + $fate = ParamFate::Used; + } elseif ($stmts === null || $name === '') { + $fate = ParamFate::Unused; + } else { + [$fate, $forwards] = $this->analyzeBody($name, $param->variadic, $stmts); } - if ($stmts === null || $name === '') { - return new ParamInfo($name, $position, ParamFate::Unused, [], false, $param->variadic, $type); - } - - [$fate, $forwards] = $this->analyzeBody($name, $param->variadic, $stmts); - - return new ParamInfo($name, $position, $fate, $forwards, false, $param->variadic, $type); + return new ParamInfo($name, $position, $fate, $forwards, $param->byRef, $param->variadic, $type); } /** @@ -70,7 +75,6 @@ private function analyzeBody(string $name, bool $variadic, array $stmts): array { $collector = new ForwardCollector($name, $variadic); $traverser = new NodeTraverser(); - $traverser->addVisitor(new ParentConnectingVisitor()); $traverser->addVisitor($collector); $traverser->traverse($stmts); From 87db09d03b63ef5c6e553cb9c43ca4397b2abc2a Mon Sep 17 00:00:00 2001 From: Lars Pohlmann Date: Tue, 11 Aug 2026 08:52:23 +0200 Subject: [PATCH 08/10] docs: add CLAUDE.md documenting local CI checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the composer scripts that mirror the CI jobs (cs/stan/md/test via composer check) and, separately, the diff-scoped Infection mutation gate (composer infection:diff, minMsi 80) that composer check does not cover and that gates PRs — run it before opening a PR. --- CLAUDE.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..903b228 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,66 @@ +# 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//` are input-codebase + expected-output pairs | + +## 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). From 0eed6009fab687c18c5b060e10955477ab87e731 Mon Sep 17 00:00:00 2001 From: Lars Pohlmann Date: Tue, 11 Aug 2026 08:53:53 +0200 Subject: [PATCH 09/10] docs: add Clean Code style section to CLAUDE.md Adapt the code-style contract from the simple-feed-reader house style: intent-revealing names, small single-purpose functions, few parameters and no behaviour-selecting boolean flags, guard clauses, immutable final value objects, typed namespaced exceptions, DRY, tests-as-production-code, and the mechanically enforced PSR-12 / PHPStan-max / PHPMD-codesize rules. --- CLAUDE.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 903b228..7d55306 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,52 @@ line were wrong, do not tune the threshold. Inspect escaped mutants in | `src/Resolve/`, `src/Chain/` | call resolution + cross-file chain stitching (Phase 2) | | `tests/**` | mirrors `src/`; `tests/fixtures//` 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, From e2a21f2ed245664d3ab6a4a1ac169a8ddc00d811 Mon Sep 17 00:00:00 2001 From: Lars Pohlmann Date: Tue, 11 Aug 2026 08:56:37 +0200 Subject: [PATCH 10/10] test: kill escaped mutants, lift diff MSI 78->88% The diff-scoped Infection PR gate (minMsi 80) failed at 78%. The escaped mutants were genuine test-suite gaps, now covered: - ArgvParser: assert every flag actually sets its Options field (the untested match arms for warn-limit/format/git-base/baseline/ generate-baseline/explain/changed-only/dump-index/help/version), plus error-message content. - Options: pin every constructor default (new OptionsTest). - FileLocator: an existing file passed as --folder throws; uppercase .PHP is matched case-insensitively. - UsageClassifier/ForwardCollector: static, nullsafe, plain-method, and function forwards record the right callee/receiver; dynamic function and dynamic new terminate as Used; first positional forward records key 0; new records the resolved class; abstract (bodyless) method param is Unused; union-typed param has null type; and a standalone classify() test proves the classifier connects its own parents. - Indexer: parse-error message names the file and reason. Also drop ParamInfo's now-unused constructor defaults (the classifier always passes every argument), removing dead defaults the mutator flagged. --- src/Index/ParamInfo.php | 8 +-- tests/Console/ArgvParserTest.php | 94 +++++++++++++++++++++++++ tests/Console/OptionsTest.php | 30 ++++++++ tests/Discovery/FileLocatorTest.php | 13 ++++ tests/Index/IndexerTest.php | 11 +++ tests/Index/UsageClassifierTest.php | 102 ++++++++++++++++++++++++++++ 6 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 tests/Console/OptionsTest.php diff --git a/src/Index/ParamInfo.php b/src/Index/ParamInfo.php index 7712fe1..667e3c3 100644 --- a/src/Index/ParamInfo.php +++ b/src/Index/ParamInfo.php @@ -16,10 +16,10 @@ public function __construct( public readonly string $name, public readonly int $position, public readonly ParamFate $fate, - public readonly array $forwards = [], - public readonly bool $byRef = false, - public readonly bool $variadic = false, - public readonly ?string $type = null, + public readonly array $forwards, + public readonly bool $byRef, + public readonly bool $variadic, + public readonly ?string $type, ) { } } diff --git a/tests/Console/ArgvParserTest.php b/tests/Console/ArgvParserTest.php index 43941a7..91f4f20 100644 --- a/tests/Console/ArgvParserTest.php +++ b/tests/Console/ArgvParserTest.php @@ -79,4 +79,98 @@ public function testUnknownFormatThrows(): void $this->expectException(InvalidArgsException::class); $this->parse('--format', 'xml'); } + + public function testWarnLimitParsed(): void + { + self::assertSame(2, $this->parse('--warn-limit', '2')->warnLimit); + } + + public function testFormatParsed(): void + { + self::assertSame('json', $this->parse('--format', 'json')->format); + } + + /** + * @return iterable + */ + public static function validFormatProvider(): iterable + { + foreach (['text', 'json', 'github', 'checkstyle', 'sarif', 'summary'] as $format) { + yield $format => [$format]; + } + } + + #[\PHPUnit\Framework\Attributes\DataProvider('validFormatProvider')] + public function testEveryDocumentedFormatIsAccepted(string $format): void + { + self::assertSame($format, $this->parse('--format', $format)->format); + } + + public function testGitBaseParsed(): void + { + self::assertSame('develop', $this->parse('--git-base', 'develop')->gitBase); + } + + public function testBaselineParsed(): void + { + self::assertSame('bl.json', $this->parse('--baseline', 'bl.json')->baseline); + } + + public function testGenerateBaselineParsed(): void + { + self::assertSame('out.json', $this->parse('--generate-baseline', 'out.json')->generateBaseline); + } + + public function testExplainFlag(): void + { + self::assertTrue($this->parse('--explain')->explain); + } + + public function testChangedOnlyFlag(): void + { + self::assertTrue($this->parse('--changed-only')->changedOnly); + } + + public function testDumpIndexFlag(): void + { + self::assertTrue($this->parse('--dump-index')->dumpIndex); + } + + public function testHelpFlags(): void + { + self::assertTrue($this->parse('--help')->help); + self::assertTrue($this->parse('-h')->help); + } + + public function testVersionFlags(): void + { + self::assertTrue($this->parse('--version')->version); + self::assertTrue($this->parse('-V')->version); + } + + public function testNonNumericWarnLimitThrows(): void + { + $this->expectException(InvalidArgsException::class); + $this->parse('--warn-limit', 'x'); + } + + public function testUnknownFormatMessageNamesTheValue(): void + { + try { + $this->parse('--format', 'xml'); + self::fail('expected InvalidArgsException'); + } catch (InvalidArgsException $e) { + self::assertStringContainsString('xml', $e->getMessage()); + } + } + + public function testMissingValueMessageNamesTheFlag(): void + { + try { + $this->parse('--limit'); + self::fail('expected InvalidArgsException'); + } catch (InvalidArgsException $e) { + self::assertStringContainsString('--limit', $e->getMessage()); + } + } } diff --git a/tests/Console/OptionsTest.php b/tests/Console/OptionsTest.php new file mode 100644 index 0000000..22da8ff --- /dev/null +++ b/tests/Console/OptionsTest.php @@ -0,0 +1,30 @@ +folders); + self::assertSame([], $options->files); + self::assertSame(3, $options->limit); + self::assertNull($options->warnLimit); + self::assertSame('text', $options->format); + self::assertFalse($options->explain); + self::assertFalse($options->changedOnly); + self::assertSame('origin/main', $options->gitBase); + self::assertNull($options->baseline); + self::assertNull($options->generateBaseline); + self::assertFalse($options->dumpIndex); + self::assertFalse($options->help); + self::assertFalse($options->version); + } +} diff --git a/tests/Discovery/FileLocatorTest.php b/tests/Discovery/FileLocatorTest.php index 9f4d6c1..ea67e6e 100644 --- a/tests/Discovery/FileLocatorTest.php +++ b/tests/Discovery/FileLocatorTest.php @@ -99,4 +99,17 @@ public function testMissingFileThrows(): void $this->expectException(InvalidArgsException::class); $this->locate(new Options(files: [$this->root . '/ghost.php'])); } + + public function testExistingFileGivenAsFolderThrows(): void + { + $this->expectException(InvalidArgsException::class); + $this->locate(new Options(folders: [$this->root . '/a.php'])); + } + + public function testUppercasePhpExtensionIsMatchedCaseInsensitively(): void + { + file_put_contents($this->root . '/Upper.PHP', "real('Upper.PHP'), $this->locate(new Options(folders: [$this->root]))); + } } diff --git a/tests/Index/IndexerTest.php b/tests/Index/IndexerTest.php index 34b4b2a..37437a1 100644 --- a/tests/Index/IndexerTest.php +++ b/tests/Index/IndexerTest.php @@ -134,4 +134,15 @@ public function testParseErrorThrowsParseException(): void $this->expectException(ParseException::class); $this->index("index("getMessage()); + self::assertStringContainsString($this->file, $e->getMessage()); + } + } } diff --git a/tests/Index/UsageClassifierTest.php b/tests/Index/UsageClassifierTest.php index 0768814..362ec6f 100644 --- a/tests/Index/UsageClassifierTest.php +++ b/tests/Index/UsageClassifierTest.php @@ -4,9 +4,14 @@ namespace PhpTramp\Tests\Index; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor\NameResolver; +use PhpParser\ParserFactory; +use PhpTramp\Index\CalleeKind; use PhpTramp\Index\Indexer; use PhpTramp\Index\ParamFate; use PhpTramp\Index\ParamInfo; +use PhpTramp\Index\UsageClassifier; use PHPUnit\Framework\TestCase; final class UsageClassifierTest extends TestCase @@ -121,6 +126,103 @@ public function testConstructorArgForwardIsPureForward(): void self::assertSame(ParamFate::PureForward, $this->classify('new Cfg($p);')['p']->fate); } + public function testStaticCallForwardRecordsStaticCallee(): void + { + $p = $this->classify('Cfg::other($p);')['p']; + self::assertSame(ParamFate::PureForward, $p->fate); + self::assertSame(CalleeKind::StaticCall, $p->forwards[0]->callee->kind); + self::assertSame('other', $p->forwards[0]->callee->name); + self::assertSame('Cfg', $p->forwards[0]->callee->receiverHint); + } + + public function testNullsafeMethodCallForwardIsPureForward(): void + { + $p = $this->classify('$svc?->handle($p);', 'Cfg $p, C $svc')['p']; + self::assertSame(ParamFate::PureForward, $p->fate); + self::assertSame(CalleeKind::Method, $p->forwards[0]->callee->kind); + } + + public function testMethodForwardRecordsCalleeAndReceiverHint(): void + { + $p = $this->classify('$svc->handle($p);', 'Cfg $p, C $svc')['p']; + self::assertSame(CalleeKind::Method, $p->forwards[0]->callee->kind); + self::assertSame('handle', $p->forwards[0]->callee->name); + self::assertSame('svc', $p->forwards[0]->callee->receiverHint); + } + + public function testFunctionForwardRecordsFuncCallee(): void + { + $p = $this->classify('other($p);')['p']; + self::assertSame(CalleeKind::Func, $p->forwards[0]->callee->kind); + self::assertSame('other', $p->forwards[0]->callee->name); + } + + public function testDynamicFunctionCalleeIsUsed(): void + { + // $fn($p): the callee is not statically nameable, so we cannot call it a + // mindless hop — the value leaves analyzable code. + self::assertSame(ParamFate::Used, $this->classify('$fn = "x"; $fn($p);')['p']->fate); + } + + public function testDynamicNewCalleeIsUsed(): void + { + self::assertSame(ParamFate::Used, $this->classify('new $cls($p);', 'Cfg $p, string $cls')['p']->fate); + } + + public function testFirstPositionalForwardRecordsPositionZero(): void + { + self::assertSame(0, $this->classify('other($p, 2);')['p']->forwards[0]->argKey); + } + + public function testNewInstantiationForwardRecordsResolvedClass(): void + { + $p = $this->classify('new Cfg($p);')['p']; + self::assertSame(CalleeKind::Instantiation, $p->forwards[0]->callee->kind); + self::assertSame('Cfg', $p->forwards[0]->callee->name); + } + + public function testAbstractMethodWithoutBodyIsUnused(): void + { + $code = 'index([$file])->get('A::m'); + self::assertNotNull($method); + self::assertSame(ParamFate::Unused, $method->params[0]->fate); + } finally { + unlink($file); + } + } + + public function testUnionTypedParamHasNullType(): void + { + $p = $this->classify('log(1);', 'Cfg|C $p')['p']; + self::assertNull($p->type); + } + + public function testClassifyConnectsParentsWithoutPriorParentTraversal(): void + { + // UsageClassifier is self-contained: given only name-resolved statements + // (no ParentConnectingVisitor run beforehand), it still detects forwards. + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $ast = $parser->parse('addVisitor(new NameResolver()); + $ast = $traverser->traverse($ast); + + $class = $ast[1]; + self::assertInstanceOf(\PhpParser\Node\Stmt\Class_::class, $class); + $method = $class->stmts[0]; + self::assertInstanceOf(\PhpParser\Node\Stmt\ClassMethod::class, $method); + + $params = (new UsageClassifier())->classify($method->params, $method->stmts); + self::assertSame(ParamFate::PureForward, $params[0]->fate); + self::assertCount(1, $params[0]->forwards); + } + public function testUntouchedParamIsUnused(): void { self::assertSame(ParamFate::Unused, $this->classify('log(1);')['p']->fate);