* [ * 'modules/Foo/Service.php' => [ * 'top' => ['config', 'total', ...], // assigned unconditionally * 'conditional' => [ * 'plugin' => ['if/else', 'if/else'], // never assigned at the top * ], * ], * ] * */ final readonly class ToApiArrayAuditor { private \PhpParser\Parser $parser; private NodeFinder $nodeFinder; public function __construct(private string $srcDir) { // Parenthesise the `new` so the chained `->createForHostVersion()` // call parses on PHP 8.3 as well as 8.4+. Without the parentheses, // PHP 8.3 sees `new ParserFactory()->...` as a syntax error: the // ability to omit them is a PHP 8.4 language change // (https://wiki.php.net/rfc/new_without_parentheses). $factory = new ParserFactory(); $this->parser = $factory->createForHostVersion(); $this->nodeFinder = new NodeFinder(); } /** * @return array, conditional: array>}> */ public function audit(): array { $findings = []; $finder = new Finder(); $finder->files()->in($this->srcDir . '/modules')->name('*.php'); foreach ($finder as $file) { $code = $file->getContents(); try { $ast = $this->parser->parse($code); } catch (\Throwable) { continue; } if ($ast === null) { continue; } $methods = $this->nodeFinder->findInstanceOf($ast, ClassMethod::class); foreach ($methods as $method) { if ($method->name->toString() !== 'toApiArray') { continue; } if ($method->stmts === null) { continue; } $relPath = str_replace($this->srcDir . '/', '', $file->getPathName()); $fields = $this->auditMethodBody($method->stmts); $top = []; $conditional = []; foreach ($fields as $name => $locations) { $hasTop = false; $conditionalLocs = []; foreach ($locations as $loc) { if ($loc === 'top') { $hasTop = true; } else { $conditionalLocs[] = $loc; } } if ($hasTop) { $top[] = $name; } elseif (!empty($conditionalLocs)) { $conditional[$name] = $conditionalLocs; } } if (!empty($conditional)) { $findings[$relPath] = [ 'top' => $top, 'conditional' => $conditional, ]; } } } ksort($findings); return $findings; } /** * Walk a list of statements and return a map of `$data['xxx']` field * names to the set of places they are assigned. * * @param Node\Stmt[] $stmts * * @return array> */ private function auditMethodBody(array $stmts): array { $result = []; foreach ($stmts as $stmt) { $this->collectAssignments($stmt, $result, 'top'); } return $result; } /** * @param array> $result */ private function collectAssignments(Node $node, array &$result, string $where): void { if ($node instanceof Node\Stmt\If_) { $childWhere = 'if'; if ($node->elseifs !== null && count($node->elseifs) > 0) { $childWhere = 'if (with elseifs)'; } if ($node->else !== null) { $childWhere = 'if/else'; } foreach ($node->stmts as $child) { $this->collectAssignments($child, $result, $childWhere); } foreach ($node->elseifs as $elseif) { foreach ($elseif->stmts as $child) { $this->collectAssignments($child, $result, $childWhere); } } if ($node->else !== null) { foreach ($node->else->stmts as $child) { $this->collectAssignments($child, $result, $childWhere); } } return; } if ($node instanceof Node\Stmt\For_ || $node instanceof Node\Stmt\Foreach_ || $node instanceof Node\Stmt\While_ || $node instanceof Node\Stmt\Do_) { $childWhere = 'loop'; foreach ($node->stmts as $child) { $this->collectAssignments($child, $result, $childWhere); } return; } if ($node instanceof Node\Stmt\Switch_) { foreach ($node->cases as $case) { foreach ($case->stmts as $child) { $this->collectAssignments($child, $result, 'switch'); } } return; } if ($node instanceof Node\Stmt\TryCatch) { $this->collectAssignments($node->try, $result, $where); foreach ($node->catches as $catch) { $this->collectAssignments($catch, $result, $where); } if ($node->finally !== null) { $this->collectAssignments($node->finally, $result, $where); } return; } if ($node instanceof Node\Stmt\Block) { foreach ($node->stmts as $child) { $this->collectAssignments($child, $result, $where); } return; } if ($node instanceof Node\Stmt\Expression) { $this->collectAssignments($node->expr, $result, $where); return; } if ($node instanceof Assign) { $field = $this->extractDataFieldName($node->var); if ($field !== null) { $result[$field][] = $where; } } } /** * Extract the field name from a `$data['xxx']` or `$data['xxx'][]` access * chain, or null if the chain doesn't terminate at a string-keyed * `$data` access. */ private function extractDataFieldName(Node $node): ?string { if ($node instanceof ArrayDimFetch) { if ($node->dim instanceof Node\Scalar\String_) { $inner = $node->var; if ($inner instanceof Variable && $inner->name === 'data') { return $node->dim->value; } } // Recurse in case of chained access like $data['x']['y']. return $this->extractDataFieldName($node->var); } return null; } }