Last active
August 6, 2026 23:42
-
-
Save ejangi/0590fbd4abd02a41bac8147ac78820d4 to your computer and use it in GitHub Desktop.
Symfony AST extraction command: src/Command/RepoMapCommand.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * REQUIRES `composer require nikic/php-parser` | |
| * | |
| * Add the following to your AGENTS.md file: | |
| * | |
| * ## Pre-Task Context Gathering | |
| * | |
| * Before proposing or generating any code changes, **always** run the Repository Map command to inspect existing interfaces, services, entities, and signatures in the codebase: | |
| * | |
| * ```bash | |
| * php bin/console app:repo-map --seeds="<Keywords, FileNames, TargetClasses or>" --top=30 | |
| * ``` | |
| */ | |
| namespace App\Command; | |
| use PhpParser\Node; | |
| use PhpParser\NodeFinder; | |
| use PhpParser\NodeTraverser; | |
| use PhpParser\NodeVisitor\NameResolver; | |
| use PhpParser\NodeVisitorAbstract; | |
| use PhpParser\ParserFactory; | |
| use PhpParser\PrettyPrinter\Standard; | |
| use Symfony\Component\Console\Attribute\AsCommand; | |
| use Symfony\Component\Console\Command\Command; | |
| use Symfony\Component\Console\Input\InputInterface; | |
| use Symfony\Component\Console\Input\InputOption; | |
| use Symfony\Component\Console\Output\OutputInterface; | |
| use Symfony\Component\Console\Style\SymfonyStyle; | |
| use Symfony\Component\Finder\Finder; | |
| #[AsCommand( | |
| name: 'app:repo-map', | |
| description: 'Generates a compressed AST + Personalized PageRank repository map for LLM Agents.' | |
| )] | |
| class RepoMapCommand extends Command | |
| { | |
| protected function configure(): void | |
| { | |
| $this | |
| ->addOption('seeds', 's', InputOption::VALUE_OPTIONAL, 'Comma-separated seed class names or paths (e.g. "OrderController,InvoiceService")', '') | |
| ->addOption('top', 't', InputOption::VALUE_OPTIONAL, 'Number of top relevant classes to include', '30') | |
| ->addOption('dir', 'd', InputOption::VALUE_OPTIONAL, 'Directory to scan relative to project root', 'src'); | |
| } | |
| protected function execute(InputInterface $input, OutputInterface $output): int | |
| { | |
| $io = new SymfonyStyle($input, $output); | |
| $projectDir = $this->getApplication()?->getKernel()?->getProjectDir() ?? getcwd(); | |
| $scanDir = $projectDir . '/' . ltrim((string)$input->getOption('dir'), '/'); | |
| if (!is_dir($scanDir)) { | |
| $io->error("Directory not found: {$scanDir}"); | |
| return Command::FAILURE; | |
| } | |
| // 1. Gather PHP files | |
| $finder = new Finder(); | |
| $finder->files()->in($scanDir)->name('*.php'); | |
| $parser = (new ParserFactory())->createForHostVersion(); | |
| $nodeFinder = new NodeFinder(); | |
| $symbols = []; // FQCN => ['node' => Stmt, 'deps' => []] | |
| $graph = []; // FQCN => [Dependency FQCNs] | |
| // 2. Extract AST & Dependencies | |
| foreach ($finder as $file) { | |
| try { | |
| $stmts = $parser->parse($file->getContents()); | |
| if (!$stmts) continue; | |
| $traverser = new NodeTraverser(); | |
| $traverser->addVisitor(new NameResolver()); | |
| $stmts = $traverser->traverse($stmts); | |
| $classNodes = $nodeFinder->find($stmts, function(Node $node) { | |
| return $node instanceof Node\Stmt\ClassLike; | |
| }); | |
| foreach ($classNodes as $classNode) { | |
| if (!$classNode instanceof Node\Stmt\ClassLike || !isset($classNode->namespacedName)) { | |
| continue; | |
| } | |
| $fqcn = $classNode->namespacedName->toString(); | |
| $deps = []; | |
| // Collect Parents & Interfaces | |
| if ($classNode instanceof Node\Stmt\Class_ && $classNode->extends) { | |
| $deps[] = $classNode->extends->toString(); | |
| } | |
| if ($classNode instanceof Node\Stmt\Class_ || $classNode instanceof Node\Stmt\Enum_) { | |
| foreach ($classNode->implements as $impl) { | |
| $deps[] = $impl->toString(); | |
| } | |
| } | |
| // Collect Constructor DI & Method Return/Param Types | |
| foreach ($classNode->getMethods() as $method) { | |
| foreach ($method->params as $param) { | |
| if ($param->type instanceof Node\Name) { | |
| $deps[] = $param->type->toString(); | |
| } | |
| } | |
| if ($method->returnType instanceof Node\Name) { | |
| $deps[] = $method->returnType->toString(); | |
| } | |
| } | |
| $deps = array_unique(array_filter($deps)); | |
| $symbols[$fqcn] = [ | |
| 'ast' => $this->stripMethodBodies($classNode), | |
| 'deps' => $deps | |
| ]; | |
| $graph[$fqcn] = $deps; | |
| } | |
| } catch (\Throwable $e) { | |
| // Skip unparseable files | |
| } | |
| } | |
| if (empty($symbols)) { | |
| $io->warning('No PHP symbols found.'); | |
| return Command::SUCCESS; | |
| } | |
| // 3. Resolve Seed Nodes | |
| $rawSeeds = array_filter(explode(',', (string)$input->getOption('seeds'))); | |
| $seedFqdns = []; | |
| foreach ($rawSeeds as $seed) { | |
| $seed = trim($seed); | |
| foreach (array_keys($symbols) as $fqcn) { | |
| if (str_contains($fqcn, $seed)) { | |
| $seedFqdns[] = $fqcn; | |
| } | |
| } | |
| } | |
| // Default to all nodes if no seeds match | |
| if (empty($seedFqdns)) { | |
| $seedFqdns = array_keys($symbols); | |
| } | |
| // 4. Run Personalized PageRank | |
| $ranks = $this->calculatePPR($graph, $seedFqdns); | |
| // 5. Render Top Stubs | |
| $topLimit = (int)$input->getOption('top'); | |
| $topSymbols = array_slice($ranks, 0, $topLimit, true); | |
| $printer = new Standard(); | |
| $output->writeln("/** REPOSITORY MAP (Top " . count($topSymbols) . " relevant symbols) **/"); | |
| $output->writeln("/** Generated for AGENTS. Reuse existing signatures below. **/\n"); | |
| foreach (array_keys($topSymbols) as $fqcn) { | |
| if (isset($symbols[$fqcn])) { | |
| $stubCode = $printer->prettyPrint([$symbols[$fqcn]['ast']]); | |
| $output->writeln($stubCode . "\n"); | |
| } | |
| } | |
| return Command::SUCCESS; | |
| } | |
| /** | |
| * Strips implementation bodies from methods to produce compact stubs. | |
| */ | |
| private function stripMethodBodies(Node\Stmt\ClassLike $node): Node\Stmt\ClassLike | |
| { | |
| $nodeCopy = clone $node; | |
| foreach ($nodeCopy->getMethods() as $method) { | |
| // Keep public and protected signatures only | |
| if ($method->isPrivate()) { | |
| continue; | |
| } | |
| $method->stmts = null; // Strips the inner body { ... } | |
| } | |
| return $nodeCopy; | |
| } | |
| /** | |
| * Personalized PageRank algorithm implementation | |
| */ | |
| private function calculatePPR(array $graph, array $seeds, float $damping = 0.85, int $iterations = 30): array | |
| { | |
| $nodes = array_keys($graph); | |
| $numNodes = count($nodes); | |
| if ($numNodes === 0) return []; | |
| $teleport = []; | |
| $seedWeight = 1.0 / count($seeds); | |
| foreach ($nodes as $node) { | |
| $teleport[$node] = in_array($node, $seeds, true) ? $seedWeight : 0.0; | |
| } | |
| $rank = $teleport; | |
| for ($i = 0; $i < $iterations; $i++) { | |
| $newRank = array_fill_keys($nodes, 0.0); | |
| foreach ($nodes as $node) { | |
| $outbound = array_intersect($graph[$node] ?? [], $nodes); | |
| $outCount = count($outbound); | |
| if ($outCount > 0) { | |
| $share = $rank[$node] / $outCount; | |
| foreach ($outbound as $target) { | |
| $newRank[$target] += $share; | |
| } | |
| } else { | |
| foreach ($seeds as $seed) { | |
| $newRank[$seed] += $rank[$node] / count($seeds); | |
| } | |
| } | |
| } | |
| foreach ($nodes as $node) { | |
| $newRank[$node] = (1 - $damping) * $teleport[$node] + $damping * $newRank[$node]; | |
| } | |
| $rank = $newRank; | |
| } | |
| arsort($rank); | |
| return $rank; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment