Skip to content

Instantly share code, notes, and snippets.

@ejangi
Last active August 6, 2026 23:42
Show Gist options
  • Select an option

  • Save ejangi/0590fbd4abd02a41bac8147ac78820d4 to your computer and use it in GitHub Desktop.

Select an option

Save ejangi/0590fbd4abd02a41bac8147ac78820d4 to your computer and use it in GitHub Desktop.
Symfony AST extraction command: src/Command/RepoMapCommand.php
<?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