CLI FEATURE
Project Graph Introspection
A mature framework should explain itself under pressure. These commands turn route and container introspection into a first-class debugging surface.
Feature Guide
A quick orientation block that answers the essential questions: what this feature does, how it works, why it matters, and the key concepts behind it.
What this does
The CLI can describe the framework graph directly: routes, modules, bindings, and handler invariants are queryable artifacts.
How it works
ai:ask route (backed by dev:graph:route) renders the payload-to-template chain, ai:ask project summarizes modules and listeners, routes:list inventories discovered endpoints, and contracts:list exposes DI bindings.
Why it matters
This shortens debugging and onboarding dramatically. Instead of reconstructing framework state by reading scattered attributes and registrations, you ask the system to explain itself.
Key concepts
- ai:ask route
- Explains one route from payload through handlers, resource, template, and auth posture.
- ai:ask project
- Summarizes modules, routes, listeners, and structural counts for the current project.
- contracts:list
- Shows which implementation is active for each registered service contract.
Explain The System
Use the CLI to inspect the framework graph instead of guessing
These commands turn route discovery, module structure, DI binding, and handler validation into explicit artifacts. That matters for both humans and AI operators when the codebase is large.
Route chain visibility. ai:ask route (backed by dev:graph:route) shows payload, handlers, resource, template, and auth posture for one endpoint.
Project-level map. ai:ask project and routes:list expose modules, counts, and discovered request surfaces.
Binding and rule checks. contracts:list and lint:* help debug DI bindings and architectural invariants before runtime incidents.
| Command | Purpose | Why it matters |
|---|---|---|
bin/semitexa ai:ask route --path=/demo/api/schema-discovery --json |
Explain the full execution chain for one route. | Ideal when a page behaves unexpectedly and you need the exact payload → handler → resource path. |
bin/semitexa ai:ask project --json |
Emit a high-level project overview with modules, routes, and listeners. | Useful for onboarding, architecture review, and AI navigation of unfamiliar projects. |
bin/semitexa routes:list --json |
List all discovered routes with source metadata. | Gives a stable route inventory instead of relying on tribal knowledge. |
bin/semitexa contracts:list --json |
Show service contracts and their active implementation. | Shortens DI debugging when multiple modules can satisfy the same interface. |
bin/semitexa lint:handlers |
Validate handler signatures, bindings, and return types. | Catches architecture drift before it becomes a runtime failure. |
Inspect one route end to end
bin/semitexa ai:ask route --path=/demo/rendering/reactive-ai --method=GET
bin/semitexa ai:ask route --path=/demo/rendering/reactive-ai --json
Map the project and routes
bin/semitexa ai:ask project --json
bin/semitexa routes:list --json
Check DI and handler invariants
bin/semitexa contracts:list --json
bin/semitexa lint:handlers
Verified against Semitexa Ultimate 2026.09.19.1020
Project Graph Introspection
A mature framework should explain itself under pressure. These commands turn route and container introspection into a first-class debugging surface.
How it works
ai:ask route --path=/… (backed by dev:graph:route) shows the full execution chain for one endpoint — payload, handlers, resource, template, and auth posture. ai:ask project and routes:list expose the module-level structure and all discovered request surfaces. ai:ask module --name=… drills into a single module. contracts:list and lint:* help validate DI bindings and architectural invariants before runtime incidents.
Why this matters
The biggest gain is not convenience. It is shortening the distance between "something feels wrong" and "here is the exact part of the system that explains it." Both human engineers and AI operators benefit from a framework that can describe its own graph instead of requiring manual reconstruction.
© Linus Torvalds: "Talk is cheap. Show me the code."
1<?php23declare(strict_types=1);45namespace Semitexa\Dev\Application\Console\Command;67use Semitexa\Core\Attribute\AsCommand;8use Semitexa\Core\Console\BaseCommand;9use Semitexa\Dev\Application\Service\Console\CommandDelegator;10use Symfony\Component\Console\Input\InputArgument;11use Symfony\Component\Console\Input\InputInterface;12use Symfony\Component\Console\Input\InputOption;13use Symfony\Component\Console\Output\OutputInterface;1415/**16 * Agent-facing aggregator for read-only introspection. One entry point, one17 * argument (the "subject"), forwarded to the underlying command that owns18 * the implementation.19 *20 * ai:ask capabilities [--json]21 * ai:ask project [--json]22 * ai:ask module --name=Billing [--json]23 * ai:ask route --path=/billing/{id} [--method=GET] [--json]24 * ai:ask event [--name=InvoicePaid] [--json]25 * ai:ask logs [--file=app] [--lines=200] [--grep=…] [--json]26 *27 * Output contracts (envelope shapes, JSON keys, exit codes) belong to the28 * dispatched targets — `ai:ask` only routes; the dev:graph:* / logs:app29 * commands own behavior.30 */31#[AsCommand(name: 'ai:ask', description: 'Agent-facing introspection aggregator (capabilities, mechanisms, project, module, route, event, path, logs)')]32final class AiAskCommand extends BaseCommand33{34 /**35 * Subject → underlying command. Keeping the list short is the point: if36 * a new read-only surface is needed, add it here rather than spawning37 * another top-level command.38 *39 * @var array<string, string>40 */41 private const SUBJECT_MAP = [42 'capabilities' => 'dev:graph:capabilities',43 // Distinct from `capabilities` on purpose: that one lists the CLI44 // commands available to run, this one lists what the installed45 // FRAMEWORK can do (deferred regions, components, live transport).46 // Both answer "what can I do here"; merging them buries the handful of47 // mechanisms under a wall of command help.48 'mechanisms' => 'dev:graph:mechanisms',49 'project' => 'dev:graph:project',50 'module' => 'dev:graph:module',51 'route' => 'dev:graph:route',52 'event' => 'dev:graph:event',53 'logs' => 'logs:app',54 // `path` explains a file/directory path using global module-structure55 // rules + any package-local extension. Auto-selected when --path is56 // passed without an explicit subject (see execute()).57 'path' => 'dev:graph:path',58 ];5960 public function __construct()61 {62 parent::__construct('ai:ask');63 }6465 protected function configure(): void66 {67 $this68 // Subject is now OPTIONAL: when omitted, the command auto-selects69 // `path` if --path is provided (lets `ai:ask --path=…` work70 // without naming a subject). Existing subjects keep their full71 // routing behavior.72 ->addArgument('subject', InputArgument::OPTIONAL, 'One of: ' . implode(', ', array_keys(self::SUBJECT_MAP)) . ' (omit if --path is provided to auto-select `path`)')73 // Union of options accepted by the delegated targets. Only the74 // ones the target actually declares are forwarded (see75 // CommandDelegator). Anything else is silently dropped.76 ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Module/event name (for subject=module|event)')77 ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Path: route path (subject=route) | file/directory path (subject=path, default when --path used)')78 ->addOption('method', null, InputOption::VALUE_REQUIRED, 'HTTP method (for subject=route)')79 ->addOption('file', null, InputOption::VALUE_REQUIRED, 'Log file alias (for subject=logs)')80 ->addOption('lines', null, InputOption::VALUE_REQUIRED, 'Line count (for subject=logs)')81 ->addOption('grep', null, InputOption::VALUE_REQUIRED, 'Filter (for subject=logs)')82 ->addOption('level', null, InputOption::VALUE_REQUIRED, 'Log level filter (for subject=logs)')83 ->addOption('since', null, InputOption::VALUE_REQUIRED, 'Time window (for subject=logs)')84 ->addOption('around', null, InputOption::VALUE_REQUIRED, 'Context timestamp (for subject=logs)')85 ->addOption('context', null, InputOption::VALUE_REQUIRED, 'Context radius (for subject=logs)')86 ->addOption('list', null, InputOption::VALUE_NONE, 'List available logs (for subject=logs)')87 ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Capability id (for subject=mechanisms)')88 ->addOption('area', null, InputOption::VALUE_REQUIRED, 'Capability area prefix, e.g. ssr|ui (for subject=mechanisms)')89 ->addOption('json', null, InputOption::VALUE_NONE, 'Emit JSON envelope (target-dependent shape)');90 }9192 protected function execute(InputInterface $input, OutputInterface $output): int93 {94 $subject = (string) ($input->getArgument('subject') ?? '');95 // When subject is omitted but --path is provided, default to96 // `path`. Keeps `ai:ask --path=foo` ergonomic for AI agents while97 // preserving the explicit-subject flow for everyone else.98 if ($subject === '' && (string) ($input->getOption('path') ?? '') !== '') {99 $subject = 'path';100 }101 if ($subject === '') {102 $output->writeln(json_encode([103 'kind' => 'error',104 'error' => 'missing subject (and no --path provided to auto-select)',105 'subjects' => array_keys(self::SUBJECT_MAP),106 ], JSON_UNESCAPED_SLASHES));107 return self::FAILURE;108 }109 $target = self::SUBJECT_MAP[$subject] ?? null;110 if ($target === null) {111 $output->writeln(json_encode([112 'kind' => 'error',113 'error' => "unknown subject '{$subject}'",114 'subjects' => array_keys(self::SUBJECT_MAP),115 ], JSON_UNESCAPED_SLASHES));116 return self::FAILURE;117 }118119 $app = $this->getApplication();120 if ($app === null) {121 $output->writeln(json_encode([122 'kind' => 'error',123 'error' => 'Application not available — cannot dispatch subject',124 ], JSON_UNESCAPED_SLASHES));125 return self::FAILURE;126 }127128 return CommandDelegator::run($app, $target, $input, $output);129 }130}131
1<?php23declare(strict_types=1);45namespace Semitexa\Dev\Application\Console\Command\DevGraph;67use Semitexa\Core\Attribute\AsCommand;8use Semitexa\Core\Console\BaseCommand;9use Semitexa\Core\Discovery\AttributeDiscovery;10use Semitexa\Core\Discovery\ClassDiscovery;11use Semitexa\Core\Discovery\RouteRegistry;12use Semitexa\Core\ModuleRegistry;13use Semitexa\Dev\Application\Service\Console\LookupRefusal;14use Symfony\Component\Console\Command\Command;15use Symfony\Component\Console\Input\InputInterface;16use Symfony\Component\Console\Input\InputOption;17use Symfony\Component\Console\Output\OutputInterface;18use Symfony\Component\Console\Style\SymfonyStyle;1920#[AsCommand(name: 'dev:graph:route', description: 'Show the full chain for a route: payload → handler → resource → template → auth')]21final class DevGraphRouteCommand extends BaseCommand22{23 /** Built lazily below; see DevGraphEventCommand. */24 private ?AttributeDiscovery $attributeDiscovery = null;25 private ?ModuleRegistry $moduleRegistry = null;26 private ?ClassDiscovery $classDiscovery = null;2728 public function __construct()29 {30 parent::__construct('dev:graph:route');31 }3233 protected function configure(): void34 {35 $this36 ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Route path (e.g., /pricing)')37 ->addOption('method', null, InputOption::VALUE_OPTIONAL, 'HTTP method (default: GET)', 'GET')38 ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON');39 }4041 protected function execute(InputInterface $input, OutputInterface $output): int42 {43 $io = new SymfonyStyle($input, $output);4445 if (!$input->getOption('path')) {46 return LookupRefusal::refuse($input, $output, 'semitexa-dev.route-description/v1', 'Missing required option: --path');47 }4849 $this->attributeDiscovery()->initialize();5051 $path = $input->getOption('path');52 $method = strtoupper($input->getOption('method') ?? 'GET');5354 $route = $this->attributeDiscovery()->findRoute($path, $method);5556 if ($route === null) {57 $routes = $this->attributeDiscovery()->getRoutes();58 foreach ($routes as $r) {59 if (($r['path'] ?? '') === $path) {60 $methods = $r['methods'] ?? [$r['method'] ?? 'GET'];61 $route = $this->attributeDiscovery()->findRoute($path, $methods[0]);62 break;63 }64 }65 }6667 if ($route === null) {68 $paths = array_map(static fn(array $r): string => (string) ($r['path'] ?? ''), $this->attributeDiscovery()->getRoutes());69 return LookupRefusal::refuse($input, $output, 'semitexa-dev.route-description/v1', "Route not found: {$method} {$path}", 'Closest routes', LookupRefusal::closest((string) $path, $paths), [70 ['cmd' => 'routes:list', 'args' => ['--json'], 'why' => 'every route with its methods'],71 ]);72 }7374 $description = $this->buildDescription($route);7576 if ($input->getOption('json')) {77 $handlerClass = null;78 if (isset($description['handlers'][0]['class'])) {79 $handlerClass = $description['handlers'][0]['class'];80 }81 $output->writeln(json_encode([82 'artifact' => 'semitexa-dev.route-description/v1',83 'generated_at' => date('c'),84 'route' => $description,85 'next_command' => $this->buildNextCommands($description, $handlerClass),86 ], JSON_UNESCAPED_SLASHES));87 return Command::SUCCESS;88 }8990 $this->renderHuman($io, $description);91 return Command::SUCCESS;92 }9394 /**95 * @param array<string, mixed> $description96 * @return list<array{cmd: string, args: list<string>, why: string}>97 */98 private function buildNextCommands(array $description, ?string $handlerClass): array99 {100 $path = (string) ($description['path'] ?? '');101 $out = [];102 if ($handlerClass !== null && $handlerClass !== '') {103 $out[] = [104 'cmd' => 'ai:review-graph:impact',105 'args' => [$handlerClass, '--json'],106 'why' => 'blast radius of changing the handler',107 ];108 $out[] = [109 'cmd' => 'ai:invoke',110 'args' => ['--handler=' . $handlerClass, '--payload={}', '--json'],111 'why' => 'dry-run the handler without starting the server',112 ];113 }114 $out[] = [115 'cmd' => 'logs:app',116 'args' => ['--grep=' . $path, '--lines=200', '--level=ERROR', '--json'],117 'why' => 'recent errors for this route',118 ];119 $out[] = [120 'cmd' => 'ai:verify',121 'args' => ['--files=' . ($description['payload']['file'] ?? '<path>'), '--json'],122 'why' => 'verify current state of the payload file',123 ];124 return $out;125 }126127 private function buildDescription(array $route): array128 {129 $payloadClass = $route['class'] ?? '';130 $methods = $route['methods'] ?? [$route['method'] ?? 'GET'];131 $responseClass = $route['responseClass'] ?? null;132 $handlers = $route['handlers'] ?? [];133 $responseAttrs = $responseClass ? $this->attributeDiscovery()->getResolvedResponseAttributes($responseClass) : null;134135 $description = [136 'path' => $route['path'] ?? '',137 'methods' => $methods,138 'name' => $route['name'] ?? null,139 'public' => ((($route['accessType'] ?? null) instanceof \Semitexa\Core\Auth\PayloadAccessType)140 ? $route['accessType']->value141 : ($route['accessType'] ?? null)) === 'public',142 'payload' => [143 'class' => $payloadClass,144 'module' => $this->moduleRegistry()->getModuleNameForClass($payloadClass) ?? 'project',145 'file' => $this->resolveRelativeFile($payloadClass),146 ],147 'resource' => null,148 'handlers' => [],149 'template' => null,150 ];151152 if ($responseClass) {153 $description['resource'] = [154 'class' => $responseClass,155 'module' => $this->moduleRegistry()->getModuleNameForClass($responseClass) ?? 'project',156 'file' => $this->resolveRelativeFile($responseClass),157 'handle' => $responseAttrs['handle'] ?? null,158 'template' => $responseAttrs['template'] ?? null,159 ];160161 if (!empty($responseAttrs['template'])) {162 $description['template'] = $responseAttrs['template'];163 }164 }165166 usort($handlers, fn ($a, $b) => ($b['priority'] ?? 0) <=> ($a['priority'] ?? 0));167 foreach ($handlers as $h) {168 $description['handlers'][] = [169 'class' => $h['class'],170 'module' => $this->moduleRegistry()->getModuleNameForClass($h['class']) ?? 'project',171 'file' => $this->resolveRelativeFile($h['class']),172 'execution' => $h['execution'] ?? 'sync',173 'priority' => $h['priority'] ?? 0,174 ];175 }176177 return $description;178 }179180 private function renderHuman(SymfonyStyle $io, array $info): void181 {182 $methodStr = implode('|', $info['methods']);183 $io->title("{$methodStr} {$info['path']}");184185 $authLabel = $info['public'] ? 'Public (no auth required)' : 'Protected (auth required)';186 $io->text("Auth: {$authLabel}");187 if ($info['name']) {188 $io->text("Name: {$info['name']}");189 }190 $io->newLine();191192 $io->section('Chain');193194 $io->text(" Payload: {$info['payload']['class']}");195 $io->text(" {$info['payload']['file']}");196197 foreach ($info['handlers'] as $h) {198 $io->text(" Handler: {$h['class']} [{$h['execution']}]");199 $io->text(" {$h['file']}");200 }201202 if ($info['resource']) {203 $io->text(" Resource: {$info['resource']['class']}");204 $io->text(" {$info['resource']['file']}");205 }206207 if ($info['template']) {208 $io->text(" Template: {$info['template']}");209 }210 }211212 private function attributeDiscovery(): AttributeDiscovery213 {214 if ($this->attributeDiscovery === null) {215 $this->attributeDiscovery = new AttributeDiscovery(216 $this->classDiscovery(),217 $this->moduleRegistry(),218 new RouteRegistry(),219 );220 }221222 return $this->attributeDiscovery;223 }224225 private function moduleRegistry(): ModuleRegistry226 {227 if ($this->moduleRegistry === null) {228 $this->moduleRegistry = new ModuleRegistry();229 }230231 return $this->moduleRegistry;232 }233234 private function classDiscovery(): ClassDiscovery235 {236 if ($this->classDiscovery === null) {237 $this->classDiscovery = new ClassDiscovery();238 }239240 return $this->classDiscovery;241 }242243 private function resolveRelativeFile(string $className): ?string244 {245 try {246 $file = (new \ReflectionClass($className))->getFileName();247 if ($file === false) {248 return null;249 }250 $root = $this->getProjectRoot();251 if (str_starts_with($file, $root)) {252 return ltrim(substr($file, strlen($root)), '/');253 }254 return $file;255 } catch (\Throwable) {256 return null;257 }258 }259}260
1<?php23declare(strict_types=1);45namespace Semitexa\Dev\Application\Console\Command\DevGraph;67use Semitexa\Core\Attribute\AsCommand;8use Semitexa\Core\Console\BaseCommand;9use Semitexa\Core\Discovery\AttributeDiscovery;10use Semitexa\Core\Discovery\ClassDiscovery;11use Semitexa\Core\Discovery\RouteRegistry;12use Semitexa\Core\Event\EventListenerRegistry;13use Semitexa\Core\ModuleRegistry;14use Symfony\Component\Console\Command\Command;15use Symfony\Component\Console\Input\InputInterface;16use Symfony\Component\Console\Input\InputOption;17use Symfony\Component\Console\Output\OutputInterface;18use Symfony\Component\Console\Style\SymfonyStyle;1920#[AsCommand(name: 'dev:graph:project', description: 'Show project overview: modules, routes, contracts, listeners')]21final class DevGraphProjectCommand extends BaseCommand22{23 private ?AttributeDiscovery $attributeDiscovery;24 private ?EventListenerRegistry $eventListenerRegistry;25 private ?ModuleRegistry $moduleRegistry;26 private ?ClassDiscovery $classDiscovery = null;2728 public function __construct(29 ?AttributeDiscovery $attributeDiscovery = null,30 ?EventListenerRegistry $eventListenerRegistry = null,31 ?ModuleRegistry $moduleRegistry = null,32 ) {33 $this->attributeDiscovery = $attributeDiscovery;34 $this->eventListenerRegistry = $eventListenerRegistry;35 $this->moduleRegistry = $moduleRegistry;36 parent::__construct('dev:graph:project');37 }3839 protected function configure(): void40 {41 $this->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON');42 }4344 protected function execute(InputInterface $input, OutputInterface $output): int45 {46 $this->attributeDiscovery()->initialize();47 $this->eventListenerRegistry()->ensureBuilt();48 $modules = $this->moduleRegistry()->getModules();49 $routes = $this->attributeDiscovery()->getRoutes();5051 $routesByModule = [];52 foreach ($routes as $route) {53 $payloadClass = $route['class'] ?? '';54 $moduleName = $this->moduleRegistry()->getModuleNameForClass($payloadClass) ?? 'project';55 $routesByModule[$moduleName] = ($routesByModule[$moduleName] ?? 0) + 1;56 }5758 $listenerClasses = $this->eventListenerRegistry()->getAllListenerClasses();59 $listenersByModule = [];60 foreach ($listenerClasses as $listenerClass) {61 $moduleName = $this->moduleRegistry()->getModuleNameForClass($listenerClass) ?? 'project';62 $listenersByModule[$moduleName] = ($listenersByModule[$moduleName] ?? 0) + 1;63 }6465 $moduleDetails = [];66 foreach ($modules as $module) {67 $name = $module['name'];68 $type = $module['type'] ?? 'unknown';69 $extends = $module['extends'] ?? null;7071 $sourceRoots = $this->resolveModuleSourceRoots($module);72 $counts = $this->countByCategory($sourceRoots);7374 $detail = [75 'name' => $name,76 'type' => $type,77 'extends' => $extends,78 'namespace' => $module['namespace'] ?? null,79 'routes' => $routesByModule[$name] ?? 0,80 'listeners' => $listenersByModule[$name] ?? 0,81 'services' => $counts['services'],82 'contracts' => $counts['contracts'],83 'events' => $counts['events'],84 'models' => $counts['models'],85 'commands' => $counts['commands'],86 ];8788 $moduleDetails[] = $detail;89 }9091 $description = [92 'total_modules' => count($modules),93 'total_routes' => count($routes),94 'total_listeners' => count($listenerClasses),95 'modules' => $moduleDetails,96 ];9798 if ($input->getOption('json')) {99 $topModuleName = $this->pickMostActiveModule($moduleDetails);100 $output->writeln(json_encode([101 'artifact' => 'semitexa-dev.project-description/v1',102 'generated_at' => date('c'),103 'project' => $description,104 'next_command' => [105 ['cmd' => 'ai:ask', 'args' => ['module', '--name=' . ($topModuleName ?? '<Module>'), '--json'], 'why' => 'drill into a module'],106 ['cmd' => 'routes:list', 'args' => ['--json'], 'why' => 'full route surface (' . count($routes) . ' routes)'],107 ['cmd' => 'contracts:list', 'args' => ['--json'], 'why' => 'interface → implementation map'],108 ],109 ], JSON_UNESCAPED_SLASHES));110 return Command::SUCCESS;111 }112113 $this->renderHuman(new SymfonyStyle($input, $output), $description);114 return Command::SUCCESS;115 }116117 private function renderHuman(SymfonyStyle $io, array $info): void118 {119 $io->title('Project Overview');120 $io->text([121 "Modules: {$info['total_modules']}",122 "Routes: {$info['total_routes']}",123 "Listeners: {$info['total_listeners']}",124 ]);125126 $io->section('Modules');127128 $tableRows = [];129 foreach ($info['modules'] as $m) {130 $counts = [];131 if ($m['routes'] > 0) {132 $counts[] = "{$m['routes']}r";133 }134 if (isset($m['services']) && $m['services'] > 0) {135 $counts[] = "{$m['services']}s";136 }137 if (isset($m['contracts']) && $m['contracts'] > 0) {138 $counts[] = "{$m['contracts']}c";139 }140 if ($m['listeners'] > 0) {141 $counts[] = "{$m['listeners']}l";142 }143 if (isset($m['events']) && $m['events'] > 0) {144 $counts[] = "{$m['events']}e";145 }146 if (isset($m['commands']) && $m['commands'] > 0) {147 $counts[] = "{$m['commands']}cmd";148 }149150 $tableRows[] = [151 $m['name'],152 $m['type'],153 $m['extends'] ?? '-',154 $counts ? implode(' ', $counts) : '-',155 ];156 }157158 $io->table(['Module', 'Type', 'Extends', 'Contents (r=routes s=services c=contracts l=listeners e=events)'], $tableRows);159 }160161 private function attributeDiscovery(): AttributeDiscovery162 {163 if ($this->attributeDiscovery === null) {164 $this->attributeDiscovery = new AttributeDiscovery(165 $this->classDiscovery(),166 $this->moduleRegistry(),167 new RouteRegistry(),168 );169 }170171 return $this->attributeDiscovery;172 }173174 private function eventListenerRegistry(): EventListenerRegistry175 {176 if ($this->eventListenerRegistry === null) {177 $this->eventListenerRegistry = new EventListenerRegistry(178 $this->classDiscovery(),179 $this->moduleRegistry(),180 );181 }182183 return $this->eventListenerRegistry;184 }185186 private function moduleRegistry(): ModuleRegistry187 {188 if ($this->moduleRegistry === null) {189 $this->moduleRegistry = new ModuleRegistry();190 }191192 return $this->moduleRegistry;193 }194195 private function classDiscovery(): ClassDiscovery196 {197 if ($this->classDiscovery === null) {198 $this->classDiscovery = new ClassDiscovery();199 }200201 return $this->classDiscovery;202 }203204 /**205 * @param list<array{name: string, routes: int, services: int, contracts: int, listeners: int, ...}> $modules206 */207 private function pickMostActiveModule(array $modules): ?string208 {209 $best = null;210 $bestScore = -1;211 foreach ($modules as $m) {212 $score = ($m['routes'] ?? 0) * 3213 + ($m['services'] ?? 0) * 2214 + ($m['contracts'] ?? 0)215 + ($m['listeners'] ?? 0);216 if ($score > $bestScore) {217 $bestScore = $score;218 $best = $m['name'];219 }220 }221 return $best;222 }223224 /**225 * Pick the directories to scan for a module. PSR-4 autoload roots are the226 * source of truth — they reflect what the module actually ships, regardless227 * of whether it lives in src/modules/, packages/, or vendor/. Falls back to228 * the module path itself when a composer.json declares no PSR-4 mapping.229 *230 * @param array<string, mixed> $module231 * @return list<string>232 */233 private function resolveModuleSourceRoots(array $module): array234 {235 $roots = [];236 $psr4 = $module['autoloadPsr4'] ?? [];237 if (is_array($psr4)) {238 foreach ($psr4 as $dirs) {239 foreach ((array) $dirs as $dir) {240 if (is_string($dir) && is_dir($dir)) {241 $roots[] = $dir;242 }243 }244 }245 }246247 if ($roots === []) {248 $path = $module['path'] ?? null;249 if (is_string($path) && is_dir($path)) {250 $roots[] = $path;251 }252 }253254 return array_values(array_unique($roots));255 }256257 /**258 * Walk PHP files under each source root and bucket them by the closest259 * matching directory-name convention (Service, Contract, Event, Model,260 * Command). Console/Command files are CLI commands and are intentionally261 * excluded from the "commands" bucket — that bucket is for application/262 * domain commands.263 *264 * @param list<string> $sourceRoots265 * @return array{services: int, contracts: int, events: int, models: int, commands: int}266 */267 private function countByCategory(array $sourceRoots): array268 {269 $counts = ['services' => 0, 'contracts' => 0, 'events' => 0, 'models' => 0, 'commands' => 0];270271 foreach ($sourceRoots as $root) {272 $realRoot = realpath($root) ?: $root;273 $iterator = new \RecursiveIteratorIterator(274 new \RecursiveDirectoryIterator($realRoot, \FilesystemIterator::SKIP_DOTS),275 \RecursiveIteratorIterator::LEAVES_ONLY,276 \RecursiveIteratorIterator::CATCH_GET_CHILD,277 );278279 foreach ($iterator as $file) {280 if (!$file instanceof \SplFileInfo || !$file->isFile() || $file->getExtension() !== 'php') {281 continue;282 }283284 $absolute = $file->getPathname();285 $relative = ltrim(substr($absolute, strlen($realRoot)), '/\\');286 $segments = explode('/', str_replace('\\', '/', $relative));287 array_pop($segments);288 if ($segments === []) {289 continue;290 }291292 $hasConsoleAncestor = in_array('Console', $segments, true);293294 foreach (array_reverse($segments) as $segment) {295 if ($segment === 'Service') {296 $counts['services']++;297 break;298 }299 if ($segment === 'Contract') {300 $counts['contracts']++;301 break;302 }303 if ($segment === 'Event') {304 $counts['events']++;305 break;306 }307 if ($segment === 'Model') {308 $counts['models']++;309 break;310 }311 if ($segment === 'Command' && !$hasConsoleAncestor) {312 $counts['commands']++;313 break;314 }315 }316 }317 }318319 return $counts;320 }321}322
1<?php23declare(strict_types=1);45namespace Semitexa\Core\Application\Console\Command;67use Semitexa\Core\Console\BaseCommand;8use Semitexa\Core\Attribute\AsCommand;9use Semitexa\Core\Discovery\AttributeDiscovery;10use Semitexa\Core\ModuleRegistry;11use Symfony\Component\Console\Command\Command;12use Symfony\Component\Console\Input\InputInterface;13use Symfony\Component\Console\Input\InputOption;14use Symfony\Component\Console\Output\OutputInterface;15use Symfony\Component\Console\Style\SymfonyStyle;1617#[AsCommand(name: 'routes:list', description: 'List all discovered routes with source information.')]18class RoutesListCommand extends BaseCommand19{20 public function __construct(21 private readonly AttributeDiscovery $attributeDiscovery,22 private readonly ModuleRegistry $moduleRegistry,23 ) {24 parent::__construct();25 }2627 protected function configure(): void28 {29 $this->setName('routes:list')30 ->setDescription('List all discovered routes with source information.')31 ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON');32 }3334 protected function execute(InputInterface $input, OutputInterface $output): int35 {36 $routes = $this->attributeDiscovery->getRoutes();37 $asJson = (bool) $input->getOption('json');3839 if ($asJson) {40 $rows = [];41 foreach ($routes as $route) {42 $rows[] = [43 'path' => $route['path'] ?? '',44 'methods' => $route['methods'] ?? [$route['method'] ?? 'GET'],45 'name' => $route['name'] ?? null,46 'class' => $route['class'] ?? '',47 'module' => $this->detectModule($route['class'] ?? ''),48 'access' => self::stringifyAccessType($route['accessType'] ?? null),49 ];50 }51 $output->writeln(json_encode($rows, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));52 return Command::SUCCESS;53 }5455 $io = new SymfonyStyle($input, $output);56 $io->title('Discovered Routes');5758 if (empty($routes)) {59 $io->warning('No routes discovered.');60 return Command::SUCCESS;61 }6263 $tableRows = [];64 foreach ($routes as $index => $route) {65 $methods = $route['methods'] ?? [$route['method'] ?? 'GET'];66 $tableRows[] = [67 (string) $index,68 implode('|', $methods),69 $route['path'] ?? '',70 $route['name'] ?? '-',71 $route['class'] ?? '',72 $this->detectModule($route['class'] ?? ''),73 self::stringifyAccessType($route['accessType'] ?? null),74 ];75 }7677 usort($tableRows, fn ($a, $b) => $a[2] <=> $b[2]);7879 $io->table(80 ['#', 'Methods', 'Path', 'Name', 'Payload Class', 'Module', 'Access'],81 $tableRows82 );8384 $io->info(count($routes) . ' route(s) discovered.');8586 return Command::SUCCESS;87 }8889 private function detectModule(string $className): string90 {91 return $this->moduleRegistry->getModuleNameForClass($className) ?? 'project';92 }9394 private static function stringifyAccessType(mixed $value): string95 {96 if ($value instanceof \Semitexa\Core\Auth\PayloadAccessType) {97 return $value->value;98 }99 if (is_string($value) && $value !== '') {100 return $value;101 }102 return 'unknown';103 }104}105
1<?php23declare(strict_types=1);45namespace Semitexa\Core\Application\Console\Command;67use Semitexa\Core\Console\BaseCommand;8use Semitexa\Core\Attribute\AsCommand;9use Semitexa\Core\Attribute\InjectAsReadonly;10use Semitexa\Core\Container\ServiceContractRegistry;11use Semitexa\Core\Discovery\ClassDiscovery;12use Semitexa\Core\ModuleRegistry;13use Semitexa\Llm\Attribute\AsAiSkill;14use Semitexa\Llm\Domain\Enum\AiConfirmationMode;15use Semitexa\Llm\Domain\Enum\AiRiskLevel;16use Symfony\Component\Console\Command\Command;17use Symfony\Component\Console\Input\InputInterface;18use Symfony\Component\Console\Input\InputOption;19use Symfony\Component\Console\Output\OutputInterface;20use Symfony\Component\Console\Style\SymfonyStyle;2122/**23 * List service contracts and which implementation is active per interface.24 * Helps developers and AI agents see contract → implementation binding and debug DI.25 */26#[AsCommand(name: 'contracts:list', description: 'List service contracts (interfaces) and their active implementation. Use when debugging which class is bound to an interface.')]27#[AsAiSkill(28 allowed: true,29 summary: 'List registered service contracts and their active implementations.',30 useWhen: 'User asks to inspect DI bindings, debug interface resolution, or see which class implements an interface.',31 avoidWhen: 'User asks to modify contracts or change DI configuration.',32 riskLevel: AiRiskLevel::Low,33 confirmation: AiConfirmationMode::Never,34 supportsDryRun: false,35 argumentPolicy: 'allowlisted',36 exposeArguments: ['json'],37)]38class ContractsListCommand extends BaseCommand39{40 #[InjectAsReadonly]41 protected ClassDiscovery $classDiscovery;4243 #[InjectAsReadonly]44 protected ModuleRegistry $moduleRegistry;4546 protected function configure(): void47 {48 $this->setName('contracts:list')49 ->setDescription('List service contracts (interfaces) and their active implementation. Use when debugging which class is bound to an interface.')50 ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON (for AI agents and scripting)');51 }5253 protected function execute(InputInterface $input, OutputInterface $output): int54 {55 $io = new SymfonyStyle($input, $output);56 $json = (bool) $input->getOption('json');5758 $registry = new ServiceContractRegistry($this->classDiscovery, $this->moduleRegistry);59 $details = $registry->getContractDetails();6061 if ($details === []) {62 if ($json) {63 $output->writeln('{"contracts":[]}');64 } else {65 $io->text('No service contracts registered. Add #[AsServiceContract(of: Interface::class)] on implementation classes in modules.');66 }67 return Command::SUCCESS;68 }6970 if ($json) {71 $this->outputJson($output, $details);72 return Command::SUCCESS;73 }7475 $this->outputTable($io, $details);76 return Command::SUCCESS;77 }7879 /**80 * Human-readable table: Contract (interface) | Implementations | Active81 * @param array<string, array{implementations: list<array{module: string, class: string}>, active: string}> $details82 */83 private function outputTable(SymfonyStyle $io, array $details): void84 {85 $rows = [];86 foreach ($details as $interface => $data) {87 $implementations = $data['implementations'];88 $active = $data['active'];8990 $implList = [];91 foreach ($implementations as $item) {92 $shortClass = $this->shortClass($item['class']);93 $mark = $item['class'] === $active ? ' ✓' : '';94 $implList[] = $item['module'] . ' → ' . $shortClass . $mark;95 }9697 $rows[] = [98 $this->shortClass($interface),99 implode("\n", $implList),100 $this->shortClass($active),101 ];102 }103104 $io->title('Service contracts (interface → implementations, active marked)');105 $io->table(106 ['Contract (interface)', 'Implementations (module → class)', 'Active'],107 $rows108 );109 $io->text('Resolution: module "extends" order (child module wins). Add #[AsServiceContract(of: Interface::class)] on implementation classes.');110 }111112 /**113 * JSON output for AI agents and scripts: stable structure, easy to parse.114 * @param array<string, array{implementations: list<array{module: string, class: string}>, active: string}> $details115 */116 private function outputJson(OutputInterface $output, array $details): void117 {118 $out = [];119 foreach ($details as $interface => $data) {120 $out[] = [121 'contract' => $interface,122 'active' => $data['active'],123 'implementations' => $data['implementations'],124 ];125 }126 $json = json_encode(['contracts' => $out], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);127 $output->writeln($json !== false ? $json : '{}');128 }129130 private function shortClass(string $fqcn): string131 {132 $parts = explode('\\', $fqcn);133 return end($parts);134 }135}136
1<?php23declare(strict_types=1);45namespace Semitexa\Core\Application\Console\Command;67use Semitexa\Core\Console\BaseCommand;8use Semitexa\Core\Attribute\AsCommand;9use Semitexa\Core\Attribute\AsPayloadHandler;10use Semitexa\Core\Contract\ResourceInterface;11use Semitexa\Core\Contract\TypedHandlerInterface;12use Semitexa\Core\Discovery\AttributeDiscovery;13use Semitexa\Core\HttpResponse;14use Symfony\Component\Console\Input\InputInterface;15use Symfony\Component\Console\Output\OutputInterface;16use Symfony\Component\Console\Style\SymfonyStyle;1718/**19 * Validate all handler signatures, return types, and payload/resource bindings.20 */21#[AsCommand(name: 'lint:handlers', description: 'Validate handler signatures, return types, and payload/resource bindings')]22final class LintHandlersCommand extends BaseCommand23{24 public function __construct(25 private readonly AttributeDiscovery $attributeDiscovery,26 ) {27 parent::__construct();28 }2930 protected function execute(InputInterface $input, OutputInterface $output): int31 {32 $io = new SymfonyStyle($input, $output);33 $io->title('Lint: Handlers');3435 $handlerClasses = $this->attributeDiscovery->getDiscoveredPayloadHandlerClassNames();36 $errors = [];3738 foreach ($handlerClasses as $handlerClass) {39 try {40 $ref = new \ReflectionClass($handlerClass);41 } catch (\Throwable $e) {42 $errors[] = "{$handlerClass}: Cannot reflect — {$e->getMessage()}";43 continue;44 }4546 // Must implement TypedHandlerInterface47 if (!$ref->implementsInterface(TypedHandlerInterface::class)) {48 $errors[] = "{$handlerClass}: Does not implement TypedHandlerInterface.";49 continue;50 }5152 // Must have handle() method53 if (!$ref->hasMethod('handle')) {54 $errors[] = "{$handlerClass}: Missing handle() method.";55 continue;56 }5758 $method = $ref->getMethod('handle');5960 // handle() must be public61 if (!$method->isPublic()) {62 $errors[] = "{$handlerClass}::handle() must be public.";63 }6465 // Must have at least 2 params66 $params = $method->getParameters();67 if (count($params) < 2) {68 $errors[] = "{$handlerClass}::handle() must accept at least 2 parameters (payload, resource).";69 continue;70 }7172 // Param 0: concrete class type73 $p0Type = $params[0]->getType();74 if (!$p0Type instanceof \ReflectionNamedType || $p0Type->isBuiltin()) {75 $errors[] = "{$handlerClass}::handle() parameter 0 must be a concrete class type.";76 }7778 // Param 1: must implement ResourceInterface. Multi-profile routes allow79 // union types (e.g. JsonResourceResponse|JsonLdResourceResponse)80 // for routes that serve multiple render profiles via Accept81 // negotiation; every component of the union must independently82 // implement ResourceInterface.83 $p1Type = $params[1]->getType();84 if ($p1Type instanceof \ReflectionUnionType) {85 foreach ($p1Type->getTypes() as $candidate) {86 if (!$candidate instanceof \ReflectionNamedType || $candidate->isBuiltin()) {87 $errors[] = "{$handlerClass}::handle() parameter 1 union must contain only concrete class types.";88 continue;89 }90 $name = $candidate->getName();91 if (!is_subclass_of($name, ResourceInterface::class) && $name !== ResourceInterface::class) {92 $errors[] = "{$handlerClass}::handle() parameter 1 union type {$name} must implement ResourceInterface.";93 }94 }95 } elseif (!$p1Type instanceof \ReflectionNamedType || $p1Type->isBuiltin()) {96 $errors[] = "{$handlerClass}::handle() parameter 1 must be a concrete ResourceInterface type.";97 } elseif (!is_subclass_of($p1Type->getName(), ResourceInterface::class)98 && $p1Type->getName() !== ResourceInterface::class) {99 $errors[] = "{$handlerClass}::handle() parameter 1 type {$p1Type->getName()} must implement ResourceInterface.";100 }101102 // Return type must not be HttpResponse103 $returnType = $method->getReturnType();104 if ($returnType instanceof \ReflectionNamedType && $returnType->getName() === HttpResponse::class) {105 $errors[] = "{$handlerClass}::handle() must return ResourceInterface, not HttpResponse.";106 }107108 // #[AsPayloadHandler] validation109 $attrs = $ref->getAttributes(AsPayloadHandler::class);110 if ($attrs !== []) {111 $attr = $attrs[0]->newInstance();112 if (!class_exists($attr->payload)) {113 $errors[] = "{$handlerClass}: Payload class {$attr->payload} does not exist.";114 }115 if (!class_exists($attr->resource)) {116 $errors[] = "{$handlerClass}: Resource class {$attr->resource} does not exist.";117 }118 }119 }120121 if ($errors === []) {122 $io->success(sprintf('All %d handlers are valid.', count($handlerClasses)));123 return self::SUCCESS;124 }125126 foreach ($errors as $error) {127 $io->error($error);128 }129 $io->error(sprintf('%d error(s) found in %d handlers.', count($errors), count($handlerClasses)));130 return self::FAILURE;131 }132}133
Operational Payoff
Where these commands save real time
The biggest gain is not convenience. It is shortening the distance between “something feels wrong” and “here is the exact part of the system that explains it.”
Reach for ai:ask route before you start manually tracing attributes across payloads, handlers, and resources.
Use routes:list and ai:ask project to orient both humans and agents in larger installations or modular monorepos.
Use contracts:list when interface resolution is ambiguous, especially in module override scenarios.
Run lints as architectural guardrails, not only as a last-minute CI formality.
How it works
ai:ask route (backed by dev:graph:route) renders the payload-to-template chain, ai:ask project summarizes modules and listeners, routes:list inventories discovered endpoints, and contracts:list exposes DI bindings.
Why it matters
This shortens debugging and onboarding dramatically. Instead of reconstructing framework state by reading scattered attributes and registrations, you ask the system to explain itself.
Key concepts
- ai:ask route
- Explains one route from payload through handlers, resource, template, and auth posture.
- ai:ask project
- Summarizes modules, routes, listeners, and structural counts for the current project.
- contracts:list
- Shows which implementation is active for each registered service contract.