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.
© Brian Kernighan: "Controlling complexity is the essence of computer programming."
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 [--search=word] [--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 api --name=Column | --search=regex [--json]26 * ai:ask components [--name=platform.grid] [--kind=component] [--json]27 * ai:ask logs [--file=app] [--lines=200] [--grep=…] [--json]28 *29 * Output contracts (envelope shapes, JSON keys, exit codes) belong to the30 * dispatched targets — `ai:ask` only routes; the dev:graph:* / logs:app31 * commands own behavior.32 */33#[AsCommand(name: 'ai:ask', description: 'Agent-facing introspection aggregator (capabilities, mechanisms, components, project, module, route, event, path, api, logs)')]34final class AiAskCommand extends BaseCommand35{36 /**37 * Subject → underlying command. Keeping the list short is the point: if38 * a new read-only surface is needed, add it here rather than spawning39 * another top-level command.40 *41 * @var array<string, string>42 */43 private const SUBJECT_MAP = [44 'capabilities' => 'dev:graph:capabilities',45 // Distinct from `capabilities` on purpose: that one lists the CLI46 // commands available to run, this one lists what the installed47 // FRAMEWORK can do (deferred regions, components, live transport).48 // Both answer "what can I do here"; merging them buries the handful of49 // mechanisms under a wall of command help.50 'mechanisms' => 'dev:graph:mechanisms',51 'project' => 'dev:graph:project',52 'module' => 'dev:graph:module',53 'route' => 'dev:graph:route',54 'event' => 'dev:graph:event',55 'api' => 'dev:graph:api',56 // The UI catalog: what can be rendered, its props and how to call it.57 'components' => 'platform-ui:catalog',58 'logs' => 'logs:app',59 // `path` explains a file/directory path using global module-structure60 // rules + any package-local extension. Auto-selected when --path is61 // passed without an explicit subject (see execute()).62 'path' => 'dev:graph:path',63 ];6465 public function __construct()66 {67 parent::__construct('ai:ask');68 }6970 protected function configure(): void71 {72 $this73 // Subject is now OPTIONAL: when omitted, the command auto-selects74 // `path` if --path is provided (lets `ai:ask --path=…` work75 // without naming a subject). Existing subjects keep their full76 // routing behavior.77 ->addArgument('subject', InputArgument::OPTIONAL, 'One of: ' . implode(', ', array_keys(self::SUBJECT_MAP)) . ' (omit if --path is provided to auto-select `path`)')78 // Union of options accepted by the delegated targets. Only the79 // ones the target actually declares are forwarded (see80 // CommandDelegator). Anything else is silently dropped.81 ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Module/event name, a framework class, or a UI entry (for subject=module|event|api|components)')82 ->addOption('search', null, InputOption::VALUE_REQUIRED, 'Find framework classes and members by name or doc summary (for subject=api), or mechanisms and commands by a word (for subject=mechanisms|capabilities)')83 ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Path: route path (subject=route) | file/directory path (subject=path, default when --path used)')84 ->addOption('method', null, InputOption::VALUE_REQUIRED, 'HTTP method (for subject=route)')85 ->addOption('file', null, InputOption::VALUE_REQUIRED, 'Log file alias (for subject=logs)')86 ->addOption('lines', null, InputOption::VALUE_REQUIRED, 'Line count (for subject=logs)')87 ->addOption('grep', null, InputOption::VALUE_REQUIRED, 'Filter (for subject=logs)')88 ->addOption('level', null, InputOption::VALUE_REQUIRED, 'Log level filter (for subject=logs)')89 ->addOption('since', null, InputOption::VALUE_REQUIRED, 'Time window (for subject=logs)')90 ->addOption('around', null, InputOption::VALUE_REQUIRED, 'Context timestamp (for subject=logs)')91 ->addOption('context', null, InputOption::VALUE_REQUIRED, 'Context radius (for subject=logs)')92 ->addOption('list', null, InputOption::VALUE_NONE, 'List available logs (for subject=logs)')93 ->addOption('kind', null, InputOption::VALUE_REQUIRED, 'primitive, component or behavior (for subject=components)')94 ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Capability id (for subject=mechanisms)')95 ->addOption('area', null, InputOption::VALUE_REQUIRED, 'Capability area prefix, e.g. ssr|ui (for subject=mechanisms)')96 ->addOption('state', null, InputOption::VALUE_REQUIRED, 'installed or available (for subject=mechanisms)')97 ->addOption('limit', null, InputOption::VALUE_REQUIRED, 'How many search hits to show (for subject=api)')98 ->addOption('with-demo', null, InputOption::VALUE_NONE, 'Also search the demo and showcase packages (for subject=api)')99 ->addOption('json', null, InputOption::VALUE_NONE, 'Emit JSON envelope (target-dependent shape)');100 }101102 protected function execute(InputInterface $input, OutputInterface $output): int103 {104 $subject = (string) ($input->getArgument('subject') ?? '');105 // When subject is omitted but --path is provided, default to106 // `path`. Keeps `ai:ask --path=foo` ergonomic for AI agents while107 // preserving the explicit-subject flow for everyone else.108 if ($subject === '' && (string) ($input->getOption('path') ?? '') !== '') {109 $subject = 'path';110 }111 if ($subject === '') {112 $output->writeln(json_encode([113 'kind' => 'error',114 'error' => 'missing subject (and no --path provided to auto-select)',115 'subjects' => array_keys(self::SUBJECT_MAP),116 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);117 return self::FAILURE;118 }119 $target = self::SUBJECT_MAP[$subject] ?? null;120 if ($target === null) {121 $output->writeln(json_encode([122 'kind' => 'error',123 'error' => "unknown subject '{$subject}'",124 'subjects' => array_keys(self::SUBJECT_MAP),125 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);126 return self::FAILURE;127 }128129 $app = $this->getApplication();130 if ($app === null) {131 $output->writeln(json_encode([132 'kind' => 'error',133 'error' => 'Application not available — cannot dispatch subject',134 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);135 return self::FAILURE;136 }137138 return CommandDelegator::run($app, $target, $input, $output);139 }140}141
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 Semitexa\Dev\Application\Service\Route\PersistenceChain;15use Symfony\Component\Console\Command\Command;16use Symfony\Component\Console\Input\InputInterface;17use Symfony\Component\Console\Input\InputOption;18use Symfony\Component\Console\Output\OutputInterface;19use Symfony\Component\Console\Style\SymfonyStyle;2021#[AsCommand(name: 'dev:graph:route', description: 'Show the full chain for a route: payload → handler → resource → template → auth')]22final class DevGraphRouteCommand extends BaseCommand23{24 /** Built lazily below; see DevGraphEventCommand. */25 private ?AttributeDiscovery $attributeDiscovery = null;26 private ?ModuleRegistry $moduleRegistry = null;27 private ?ClassDiscovery $classDiscovery = null;2829 public function __construct()30 {31 parent::__construct('dev:graph:route');32 }3334 protected function configure(): void35 {36 $this37 ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Route path (e.g., /pricing)')38 // No option default: null is how "not given" is told from an explicit --method=GET.39 ->addOption('method', null, InputOption::VALUE_OPTIONAL, 'HTTP method (default: GET, else the first one the path serves)')40 ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON');41 }4243 protected function execute(InputInterface $input, OutputInterface $output): int44 {45 $io = new SymfonyStyle($input, $output);4647 if (!$input->getOption('path')) {48 return LookupRefusal::refuse($input, $output, 'semitexa-dev.route-description/v1', 'Missing required option: --path');49 }5051 $this->attributeDiscovery()->initialize();5253 $path = $input->getOption('path');54 $method = strtoupper($input->getOption('method') ?? 'GET');5556 $route = $this->attributeDiscovery()->findRoute($path, $method);5758 if ($route === null) {59 $served = [];60 foreach ($this->attributeDiscovery()->getRoutes() as $r) {61 if (($r['path'] ?? '') === $path) {62 foreach ((array) ($r['methods'] ?? [$r['method'] ?? 'GET']) as $m) {63 $served[] = strtoupper((string) $m);64 }65 }66 }67 $served = array_values(array_unique($served));68 // An explicit --method the path does not serve used to be swapped69 // for another method without a word: POST answered with the GET route.70 if ($served !== [] && $input->getOption('method') !== null) {71 return LookupRefusal::refuse($input, $output, 'semitexa-dev.route-description/v1', sprintf('%s %s is not served; the path answers %s.', $method, $path, implode(', ', $served)));72 }73 if ($served !== []) {74 $method = $served[0];75 $route = $this->attributeDiscovery()->findRoute($path, $method);76 if (!$input->getOption('json')) {77 $io->note(sprintf('No --method given and GET is not served; describing %s.', $method));78 }79 }80 }8182 if ($route === null) {83 $paths = array_map(static fn(array $r): string => (string) ($r['path'] ?? ''), $this->attributeDiscovery()->getRoutes());84 return LookupRefusal::refuse($input, $output, 'semitexa-dev.route-description/v1', "Route not found: {$method} {$path}", 'Closest routes', LookupRefusal::closest((string) $path, $paths), [85 ['cmd' => 'routes:list', 'args' => ['--json'], 'why' => 'every route with its methods'],86 ]);87 }8889 $description = $this->buildDescription($route);9091 if ($input->getOption('json')) {92 $handlerClass = null;93 if (isset($description['handlers'][0]['class'])) {94 $handlerClass = $description['handlers'][0]['class'];95 }96 $output->writeln(json_encode([97 'artifact' => 'semitexa-dev.route-description/v1',98 'generated_at' => date('c'),99 'route' => $description,100 'next_command' => $this->buildNextCommands($description, $handlerClass),101 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);102 return Command::SUCCESS;103 }104105 $this->renderHuman($io, $description);106 return Command::SUCCESS;107 }108109 /**110 * @param array<string, mixed> $description111 * @return list<array{cmd: string, args: list<string>, why: string}>112 */113 private function buildNextCommands(array $description, ?string $handlerClass): array114 {115 $path = (string) ($description['path'] ?? '');116 $out = [];117 if ($handlerClass !== null && $handlerClass !== '') {118 $out[] = [119 'cmd' => 'ai:review-graph:impact',120 'args' => [$handlerClass, '--json'],121 'why' => 'blast radius of changing the handler',122 ];123 $out[] = [124 'cmd' => 'ai:invoke',125 'args' => ['--handler=' . $handlerClass, '--payload={}', '--json'],126 'why' => 'dry-run the handler without starting the server',127 ];128 }129 $out[] = [130 'cmd' => 'logs:app',131 'args' => ['--grep=' . $path, '--lines=200', '--level=ERROR', '--json'],132 'why' => 'recent errors for this route',133 ];134 $out[] = [135 'cmd' => 'ai:verify',136 'args' => ['--files=' . ($description['payload']['file'] ?? '<path>'), '--json'],137 'why' => 'verify current state of the payload file',138 ];139 return $out;140 }141142 private function buildDescription(array $route): array143 {144 $payloadClass = $route['class'] ?? '';145 $methods = $route['methods'] ?? [$route['method'] ?? 'GET'];146 $responseClass = $route['responseClass'] ?? null;147 $handlers = $route['handlers'] ?? [];148 $responseAttrs = $responseClass ? $this->attributeDiscovery()->getResolvedResponseAttributes($responseClass) : null;149150 $description = [151 'path' => $route['path'] ?? '',152 'methods' => $methods,153 'name' => $route['name'] ?? null,154 'public' => ((($route['accessType'] ?? null) instanceof \Semitexa\Core\Auth\PayloadAccessType)155 ? $route['accessType']->value156 : ($route['accessType'] ?? null)) === 'public',157 'payload' => [158 'class' => $payloadClass,159 'module' => $this->moduleRegistry()->getModuleNameForClass($payloadClass) ?? 'project',160 'file' => $this->resolveRelativeFile($payloadClass),161 ],162 'resource' => null,163 'handlers' => [],164 'template' => null,165 ];166167 if ($responseClass) {168 $description['resource'] = [169 'class' => $responseClass,170 'module' => $this->moduleRegistry()->getModuleNameForClass($responseClass) ?? 'project',171 'file' => $this->resolveRelativeFile($responseClass),172 'handle' => $responseAttrs['handle'] ?? null,173 'template' => $responseAttrs['template'] ?? null,174 ];175176 if (!empty($responseAttrs['template'])) {177 $description['template'] = $responseAttrs['template'];178 }179 }180181 usort($handlers, fn ($a, $b) => ($b['priority'] ?? 0) <=> ($a['priority'] ?? 0));182 foreach ($handlers as $h) {183 $description['handlers'][] = [184 'class' => $h['class'],185 'module' => $this->moduleRegistry()->getModuleNameForClass($h['class']) ?? 'project',186 'file' => $this->resolveRelativeFile($h['class']),187 'execution' => $h['execution'] ?? 'sync',188 'priority' => $h['priority'] ?? 0,189 ];190 }191192 $chain = (new PersistenceChain($this->classDiscovery()))193 ->of([$payloadClass, ...array_column($description['handlers'], 'class')]);194 $description['persistence'] = $chain['tables'];195 $description['persistence_unreadable'] = $chain['unreadable'];196 $description['persistence_unloadable_in_project'] = $chain['unloadable_in_project'];197 $description['persistence_stopped_at'] = $chain['stopped_at'];198199 return $description;200 }201202 private function renderHuman(SymfonyStyle $io, array $info): void203 {204 $methodStr = implode('|', $info['methods']);205 $io->title("{$methodStr} {$info['path']}");206207 $authLabel = $info['public'] ? 'Public (no auth required)' : 'Protected (auth required)';208 $io->text("Auth: {$authLabel}");209 if ($info['name']) {210 $io->text("Name: {$info['name']}");211 }212 $io->newLine();213214 $io->section('Chain');215216 $io->text(" Payload: {$info['payload']['class']}");217 $io->text(" {$info['payload']['file']}");218219 foreach ($info['handlers'] as $h) {220 $io->text(" Handler: {$h['class']} [{$h['execution']}]");221 $io->text(" {$h['file']}");222 }223224 if ($info['resource']) {225 $io->text(" Resource: {$info['resource']['class']}");226 $io->text(" {$info['resource']['file']}");227 }228229 if ($info['template']) {230 $io->text(" Template: {$info['template']}");231 }232233 foreach ($info['persistence'] as $chain) {234 $io->newLine();235 $io->section("Persistence: {$chain['table']}");236 foreach (['resource' => 'Resource', 'mapper' => 'Mapper', 'domain' => 'Domain'] as $key => $label) {237 if ($chain[$key] !== null) {238 $io->text(sprintf(' %-11s %s', $label . ':', $chain[$key]));239 }240 }241 foreach ($chain['repositories'] as $repository) {242 $io->text(sprintf(' %-11s %s', 'Repository:', $repository));243 }244 $io->text(sprintf(' %-11s %s', 'Named by:', $chain['named_by']));245 }246 if ($info['persistence_unreadable'] !== []) {247 $io->warning('Not followed, these classes did not load: ' . implode(', ', $info['persistence_unreadable']));248 }249 if ($info['persistence_unloadable_in_project'] !== []) {250 $io->note('Not loadable anywhere in the project: ' . implode(', ', $info['persistence_unloadable_in_project']));251 }252 if ($info['persistence_stopped_at'] !== []) {253 $io->note('The walk stopped at: ' . implode(', ', $info['persistence_stopped_at']));254 }255 }256257 private function attributeDiscovery(): AttributeDiscovery258 {259 if ($this->attributeDiscovery === null) {260 $this->attributeDiscovery = new AttributeDiscovery(261 $this->classDiscovery(),262 $this->moduleRegistry(),263 new RouteRegistry(),264 );265 }266267 return $this->attributeDiscovery;268 }269270 private function moduleRegistry(): ModuleRegistry271 {272 if ($this->moduleRegistry === null) {273 $this->moduleRegistry = new ModuleRegistry();274 }275276 return $this->moduleRegistry;277 }278279 private function classDiscovery(): ClassDiscovery280 {281 if ($this->classDiscovery === null) {282 $this->classDiscovery = new ClassDiscovery();283 }284285 return $this->classDiscovery;286 }287288 private function resolveRelativeFile(string $className): ?string289 {290 try {291 $file = (new \ReflectionClass($className))->getFileName();292 if ($file === false) {293 return null;294 }295 $root = $this->getProjectRoot();296 if (str_starts_with($file, $root)) {297 return ltrim(substr($file, strlen($root)), '/');298 }299 return $file;300 } catch (\Throwable) {301 return null;302 }303 }304}305
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 /** Built lazily by the getters below, as in DevGraphModuleCommand: nobody passed them in. */24 private ?AttributeDiscovery $attributeDiscovery = null;25 private ?EventListenerRegistry $eventListenerRegistry = null;26 private ?ModuleRegistry $moduleRegistry = null;27 private ?ClassDiscovery $classDiscovery = null;2829 public function __construct()30 {31 parent::__construct('dev:graph:project');32 }3334 protected function configure(): void35 {36 $this->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON');37 }3839 protected function execute(InputInterface $input, OutputInterface $output): int40 {41 $this->attributeDiscovery()->initialize();42 $this->eventListenerRegistry()->ensureBuilt();43 $modules = $this->moduleRegistry()->getModules();44 $routes = $this->attributeDiscovery()->getRoutes();4546 $routesByModule = [];47 foreach ($routes as $route) {48 $payloadClass = $route['class'] ?? '';49 $moduleName = $this->moduleRegistry()->getModuleNameForClass($payloadClass) ?? 'project';50 $routesByModule[$moduleName] = ($routesByModule[$moduleName] ?? 0) + 1;51 }5253 $listenerClasses = $this->eventListenerRegistry()->getAllListenerClasses();54 $listenersByModule = [];55 foreach ($listenerClasses as $listenerClass) {56 $moduleName = $this->moduleRegistry()->getModuleNameForClass($listenerClass) ?? 'project';57 $listenersByModule[$moduleName] = ($listenersByModule[$moduleName] ?? 0) + 1;58 }5960 $moduleDetails = [];61 foreach ($modules as $module) {62 $name = $module['name'];63 $type = $module['type'] ?? 'unknown';64 $extends = $module['extends'] ?? null;6566 $sourceRoots = $this->resolveModuleSourceRoots($module);67 $counts = $this->countByCategory($sourceRoots);6869 $detail = [70 'name' => $name,71 'type' => $type,72 'extends' => $extends,73 'namespace' => $module['namespace'] ?? null,74 'routes' => $routesByModule[$name] ?? 0,75 'listeners' => $listenersByModule[$name] ?? 0,76 'services' => $counts['services'],77 'contracts' => $counts['contracts'],78 'events' => $counts['events'],79 'models' => $counts['models'],80 'commands' => $counts['commands'],81 ];8283 $moduleDetails[] = $detail;84 }8586 $description = [87 'total_modules' => count($modules),88 'total_routes' => count($routes),89 'total_listeners' => count($listenerClasses),90 'modules' => $moduleDetails,91 ];9293 if ($input->getOption('json')) {94 $topModuleName = $this->pickMostActiveModule($moduleDetails);95 $output->writeln(json_encode([96 'artifact' => 'semitexa-dev.project-description/v1',97 'generated_at' => date('c'),98 'project' => $description,99 'next_command' => [100 ['cmd' => 'ai:ask', 'args' => ['module', '--name=' . ($topModuleName ?? '<Module>'), '--json'], 'why' => 'drill into a module'],101 ['cmd' => 'routes:list', 'args' => ['--json'], 'why' => 'full route surface (' . count($routes) . ' routes)'],102 ['cmd' => 'contracts:list', 'args' => ['--json'], 'why' => 'interface → implementation map'],103 ],104 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);105 return Command::SUCCESS;106 }107108 $this->renderHuman(new SymfonyStyle($input, $output), $description);109 return Command::SUCCESS;110 }111112 private function renderHuman(SymfonyStyle $io, array $info): void113 {114 $io->title('Project Overview');115 $io->text([116 "Modules: {$info['total_modules']}",117 "Routes: {$info['total_routes']}",118 "Listeners: {$info['total_listeners']}",119 ]);120121 $io->section('Modules');122123 $tableRows = [];124 foreach ($info['modules'] as $m) {125 $counts = [];126 if ($m['routes'] > 0) {127 $counts[] = "{$m['routes']}r";128 }129 if (isset($m['services']) && $m['services'] > 0) {130 $counts[] = "{$m['services']}s";131 }132 if (isset($m['contracts']) && $m['contracts'] > 0) {133 $counts[] = "{$m['contracts']}c";134 }135 if ($m['listeners'] > 0) {136 $counts[] = "{$m['listeners']}l";137 }138 if (isset($m['events']) && $m['events'] > 0) {139 $counts[] = "{$m['events']}e";140 }141 if (isset($m['commands']) && $m['commands'] > 0) {142 $counts[] = "{$m['commands']}cmd";143 }144145 $tableRows[] = [146 $m['name'],147 $m['type'],148 $m['extends'] ?? '-',149 $counts ? implode(' ', $counts) : '-',150 ];151 }152153 $io->table(['Module', 'Type', 'Extends', 'Contents (r=routes s=services c=contracts l=listeners e=events)'], $tableRows);154 }155156 private function attributeDiscovery(): AttributeDiscovery157 {158 if ($this->attributeDiscovery === null) {159 $this->attributeDiscovery = new AttributeDiscovery(160 $this->classDiscovery(),161 $this->moduleRegistry(),162 new RouteRegistry(),163 );164 }165166 return $this->attributeDiscovery;167 }168169 private function eventListenerRegistry(): EventListenerRegistry170 {171 if ($this->eventListenerRegistry === null) {172 $this->eventListenerRegistry = new EventListenerRegistry(173 $this->classDiscovery(),174 $this->moduleRegistry(),175 );176 }177178 return $this->eventListenerRegistry;179 }180181 private function moduleRegistry(): ModuleRegistry182 {183 if ($this->moduleRegistry === null) {184 $this->moduleRegistry = new ModuleRegistry();185 }186187 return $this->moduleRegistry;188 }189190 private function classDiscovery(): ClassDiscovery191 {192 if ($this->classDiscovery === null) {193 $this->classDiscovery = new ClassDiscovery();194 }195196 return $this->classDiscovery;197 }198199 /**200 * @param list<array{name: string, routes: int, services: int, contracts: int, listeners: int, ...}> $modules201 */202 private function pickMostActiveModule(array $modules): ?string203 {204 $best = null;205 $bestScore = -1;206 foreach ($modules as $m) {207 $score = ($m['routes'] ?? 0) * 3208 + ($m['services'] ?? 0) * 2209 + ($m['contracts'] ?? 0)210 + ($m['listeners'] ?? 0);211 if ($score > $bestScore) {212 $bestScore = $score;213 $best = $m['name'];214 }215 }216 return $best;217 }218219 /**220 * Pick the directories to scan for a module. PSR-4 autoload roots are the221 * source of truth — they reflect what the module actually ships, regardless222 * of whether it lives in src/modules/, packages/, or vendor/. Falls back to223 * the module path itself when a composer.json declares no PSR-4 mapping.224 *225 * @param array<string, mixed> $module226 * @return list<string>227 */228 private function resolveModuleSourceRoots(array $module): array229 {230 $roots = [];231 $psr4 = $module['autoloadPsr4'] ?? [];232 if (is_array($psr4)) {233 foreach ($psr4 as $dirs) {234 foreach ((array) $dirs as $dir) {235 if (is_string($dir) && is_dir($dir)) {236 $roots[] = $dir;237 }238 }239 }240 }241242 if ($roots === []) {243 $path = $module['path'] ?? null;244 if (is_string($path) && is_dir($path)) {245 $roots[] = $path;246 }247 }248249 return array_values(array_unique($roots));250 }251252 /**253 * Walk PHP files under each source root and bucket them by the closest254 * matching directory-name convention (Service, Contract, Event, Model,255 * Command). Console/Command files are CLI commands and are intentionally256 * excluded from the "commands" bucket — that bucket is for application/257 * domain commands.258 *259 * @param list<string> $sourceRoots260 * @return array{services: int, contracts: int, events: int, models: int, commands: int}261 */262 private function countByCategory(array $sourceRoots): array263 {264 $counts = ['services' => 0, 'contracts' => 0, 'events' => 0, 'models' => 0, 'commands' => 0];265266 foreach ($sourceRoots as $root) {267 $realRoot = realpath($root) ?: $root;268 $iterator = new \RecursiveIteratorIterator(269 new \RecursiveDirectoryIterator($realRoot, \FilesystemIterator::SKIP_DOTS),270 \RecursiveIteratorIterator::LEAVES_ONLY,271 \RecursiveIteratorIterator::CATCH_GET_CHILD,272 );273274 foreach ($iterator as $file) {275 if (!$file instanceof \SplFileInfo || !$file->isFile() || $file->getExtension() !== 'php') {276 continue;277 }278279 $absolute = $file->getPathname();280 $relative = ltrim(substr($absolute, strlen($realRoot)), '/\\');281 $segments = explode('/', str_replace('\\', '/', $relative));282 array_pop($segments);283 if ($segments === []) {284 continue;285 }286287 $hasConsoleAncestor = in_array('Console', $segments, true);288289 foreach (array_reverse($segments) as $segment) {290 if ($segment === 'Service') {291 $counts['services']++;292 break;293 }294 if ($segment === 'Contract') {295 $counts['contracts']++;296 break;297 }298 if ($segment === 'Event') {299 $counts['events']++;300 break;301 }302 if ($segment === 'Model') {303 $counts['models']++;304 break;305 }306 if ($segment === 'Command' && !$hasConsoleAncestor) {307 $counts['commands']++;308 break;309 }310 }311 }312 }313314 return $counts;315 }316}317
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\Discovery\AttributeDiscovery;11use Semitexa\Core\ModuleRegistry;12use Symfony\Component\Console\Command\Command;13use Symfony\Component\Console\Input\InputInterface;14use Symfony\Component\Console\Input\InputOption;15use Symfony\Component\Console\Output\OutputInterface;16use Symfony\Component\Console\Style\SymfonyStyle;1718#[AsCommand(name: 'routes:list', description: 'List all discovered routes with source information.')]19class RoutesListCommand extends BaseCommand20{21 #[InjectAsReadonly]22 protected AttributeDiscovery $attributeDiscovery;2324 #[InjectAsReadonly]25 protected ModuleRegistry $moduleRegistry;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 $index => $route) {42 $rows[] = [43 // What routes:show takes: the only way to reach one of two routes on a path.44 'index' => $index,45 'path' => $route['path'] ?? '',46 'methods' => $route['methods'] ?? [$route['method'] ?? 'GET'],47 'name' => $route['name'] ?? null,48 'class' => $route['class'] ?? '',49 'module' => $this->detectModule($route['class'] ?? ''),50 'access' => self::stringifyAccessType($route['accessType'] ?? null),51 ];52 }53 $output->writeln(json_encode($rows, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), OutputInterface::OUTPUT_RAW);54 return Command::SUCCESS;55 }5657 $io = new SymfonyStyle($input, $output);58 $io->title('Discovered Routes');5960 if (empty($routes)) {61 $io->warning('No routes discovered.');62 return Command::SUCCESS;63 }6465 $tableRows = [];66 foreach ($routes as $index => $route) {67 $methods = $route['methods'] ?? [$route['method'] ?? 'GET'];68 $tableRows[] = [69 (string) $index,70 implode('|', $methods),71 $route['path'] ?? '',72 $route['name'] ?? '-',73 $route['class'] ?? '',74 $this->detectModule($route['class'] ?? ''),75 self::stringifyAccessType($route['accessType'] ?? null),76 ];77 }7879 usort($tableRows, fn ($a, $b) => $a[2] <=> $b[2]);8081 $io->table(82 ['#', 'Methods', 'Path', 'Name', 'Payload Class', 'Module', 'Access'],83 $tableRows84 );8586 $io->info(count($routes) . ' route(s) discovered.');8788 return Command::SUCCESS;89 }9091 private function detectModule(string $className): string92 {93 return $this->moduleRegistry->getModuleNameForClass($className) ?? 'project';94 }9596 private static function stringifyAccessType(mixed $value): string97 {98 if ($value instanceof \Semitexa\Core\Auth\PayloadAccessType) {99 return $value->value;100 }101 if (is_string($value) && $value !== '') {102 return $value;103 }104 return 'unknown';105 }106}107
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 // Acts for the whole install, not one tenant: an operator's tool. It was30 // hidden from tenants only because core is not a module (30.10).31 operatorOnly: true,32 summary: 'List registered service contracts and their active implementations.',33 useWhen: 'User asks to inspect DI bindings, debug interface resolution, or see which class implements an interface.',34 avoidWhen: 'User asks to modify contracts or change DI configuration.',35 riskLevel: AiRiskLevel::Low,36 confirmation: AiConfirmationMode::Never,37 supportsDryRun: false,38 argumentPolicy: 'allowlisted',39 exposeArguments: ['json'],40)]41class ContractsListCommand extends BaseCommand42{43 #[InjectAsReadonly]44 protected ClassDiscovery $classDiscovery;4546 #[InjectAsReadonly]47 protected ModuleRegistry $moduleRegistry;4849 protected function configure(): void50 {51 $this->setName('contracts:list')52 ->setDescription('List service contracts (interfaces) and their active implementation. Use when debugging which class is bound to an interface.')53 ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON (for AI agents and scripting)');54 }5556 protected function execute(InputInterface $input, OutputInterface $output): int57 {58 $io = new SymfonyStyle($input, $output);59 $json = (bool) $input->getOption('json');6061 $registry = new ServiceContractRegistry($this->classDiscovery, $this->moduleRegistry);62 $details = $registry->getContractDetails();6364 if ($details === []) {65 if ($json) {66 $output->writeln('{"contracts":[]}');67 } else {68 $io->text('No service contracts registered. Add #[AsServiceContract(of: Interface::class)] on implementation classes in modules.');69 }70 return Command::SUCCESS;71 }7273 if ($json) {74 $this->outputJson($output, $details);75 return Command::SUCCESS;76 }7778 $this->outputTable($io, $details);79 return Command::SUCCESS;80 }8182 /**83 * Human-readable table: Contract (interface) | Implementations | Active84 * @param array<string, array{implementations: list<array{module: string, class: string}>, active: string}> $details85 */86 private function outputTable(SymfonyStyle $io, array $details): void87 {88 $rows = [];89 foreach ($details as $interface => $data) {90 $implementations = $data['implementations'];91 $active = $data['active'];9293 $implList = [];94 foreach ($implementations as $item) {95 $shortClass = $this->shortClass($item['class']);96 $mark = $item['class'] === $active ? ' ✓' : '';97 $implList[] = $item['module'] . ' → ' . $shortClass . $mark;98 }99100 $rows[] = [101 $this->shortClass($interface),102 implode("\n", $implList),103 $this->shortClass($active),104 ];105 }106107 $io->title('Service contracts (interface → implementations, active marked)');108 $io->table(109 ['Contract (interface)', 'Implementations (module → class)', 'Active'],110 $rows111 );112 $io->text('Resolution: module "extends" order (child module wins). Add #[AsServiceContract(of: Interface::class)] on implementation classes.');113 }114115 /**116 * JSON output for AI agents and scripts: stable structure, easy to parse.117 * @param array<string, array{implementations: list<array{module: string, class: string}>, active: string}> $details118 */119 private function outputJson(OutputInterface $output, array $details): void120 {121 $out = [];122 foreach ($details as $interface => $data) {123 $out[] = [124 'contract' => $interface,125 'active' => $data['active'],126 'implementations' => $data['implementations'],127 ];128 }129 $json = json_encode(['contracts' => $out], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);130 $output->writeln($json !== false ? $json : '{}');131 }132133 private function shortClass(string $fqcn): string134 {135 $parts = explode('\\', $fqcn);136 return end($parts);137 }138}139
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\Attribute\AsPayloadHandler;11use Semitexa\Core\Contract\ResourceInterface;12use Semitexa\Core\Contract\TypedHandlerInterface;13use Semitexa\Core\Discovery\AttributeDiscovery;14use Semitexa\Core\HttpResponse;15use Symfony\Component\Console\Input\InputInterface;16use Symfony\Component\Console\Output\OutputInterface;17use Symfony\Component\Console\Style\SymfonyStyle;1819/**20 * Validate all handler signatures, return types, and payload/resource bindings.21 */22#[AsCommand(name: 'lint:handlers', description: 'Validate handler signatures, return types, and payload/resource bindings')]23final class LintHandlersCommand extends BaseCommand24{25 /** Why this check exists, and what taught us; ai:verify prints it when the lint fails. */26 public const RATIONALE = 'Why: handler signatures and payload/resource bindings are resolved by reflection, so a wrong one is otherwise found by the boot or request that first reaches it rather than before the change lands. Policy since 2026-04-30; no incident on record.';2728 #[InjectAsReadonly]29 protected AttributeDiscovery $attributeDiscovery;3031 protected function execute(InputInterface $input, OutputInterface $output): int32 {33 $io = new SymfonyStyle($input, $output);34 $io->title('Lint: Handlers');3536 $handlerClasses = $this->attributeDiscovery->getDiscoveredPayloadHandlerClassNames();37 $errors = [];3839 foreach ($handlerClasses as $handlerClass) {40 try {41 $ref = new \ReflectionClass($handlerClass);42 } catch (\Throwable $e) {43 $errors[] = "{$handlerClass}: Cannot reflect — {$e->getMessage()}";44 continue;45 }4647 // Must implement TypedHandlerInterface48 if (!$ref->implementsInterface(TypedHandlerInterface::class)) {49 $errors[] = "{$handlerClass}: Does not implement TypedHandlerInterface.";50 continue;51 }5253 // Must have handle() method54 if (!$ref->hasMethod('handle')) {55 $errors[] = "{$handlerClass}: Missing handle() method.";56 continue;57 }5859 $method = $ref->getMethod('handle');6061 // handle() must be public62 if (!$method->isPublic()) {63 $errors[] = "{$handlerClass}::handle() must be public.";64 }6566 // Must have at least 2 params67 $params = $method->getParameters();68 if (count($params) < 2) {69 $errors[] = "{$handlerClass}::handle() must accept at least 2 parameters (payload, resource).";70 continue;71 }7273 // Param 0: concrete class type74 $p0Type = $params[0]->getType();75 if (!$p0Type instanceof \ReflectionNamedType || $p0Type->isBuiltin()) {76 $errors[] = "{$handlerClass}::handle() parameter 0 must be a concrete class type.";77 }7879 // Param 1: must implement ResourceInterface. Multi-profile routes allow80 // union types (e.g. JsonResourceResponse|JsonLdResourceResponse)81 // for routes that serve multiple render profiles via Accept82 // negotiation; every component of the union must independently83 // implement ResourceInterface.84 $p1Type = $params[1]->getType();85 if ($p1Type instanceof \ReflectionUnionType) {86 foreach ($p1Type->getTypes() as $candidate) {87 if (!$candidate instanceof \ReflectionNamedType || $candidate->isBuiltin()) {88 $errors[] = "{$handlerClass}::handle() parameter 1 union must contain only concrete class types.";89 continue;90 }91 $name = $candidate->getName();92 if (!is_subclass_of($name, ResourceInterface::class) && $name !== ResourceInterface::class) {93 $errors[] = "{$handlerClass}::handle() parameter 1 union type {$name} must implement ResourceInterface.";94 }95 }96 } elseif (!$p1Type instanceof \ReflectionNamedType || $p1Type->isBuiltin()) {97 $errors[] = "{$handlerClass}::handle() parameter 1 must be a concrete ResourceInterface type.";98 } elseif (!is_subclass_of($p1Type->getName(), ResourceInterface::class)99 && $p1Type->getName() !== ResourceInterface::class) {100 $errors[] = "{$handlerClass}::handle() parameter 1 type {$p1Type->getName()} must implement ResourceInterface.";101 }102103 // Return type must not be HttpResponse104 $returnType = $method->getReturnType();105 if ($returnType instanceof \ReflectionNamedType && $returnType->getName() === HttpResponse::class) {106 $errors[] = "{$handlerClass}::handle() must return ResourceInterface, not HttpResponse.";107 }108109 // #[AsPayloadHandler] validation110 $attrs = $ref->getAttributes(AsPayloadHandler::class);111 if ($attrs !== []) {112 $attr = $attrs[0]->newInstance();113 if (!class_exists($attr->payload)) {114 $errors[] = "{$handlerClass}: Payload class {$attr->payload} does not exist.";115 }116 if (!class_exists($attr->resource)) {117 $errors[] = "{$handlerClass}: Resource class {$attr->resource} does not exist.";118 }119 }120 }121122 if ($errors === []) {123 $io->success(sprintf('All %d handlers are valid.', count($handlerClasses)));124 return self::SUCCESS;125 }126127 foreach ($errors as $error) {128 $io->error($error);129 }130 $io->error(sprintf('%d error(s) found in %d handlers.', count($errors), count($handlerClasses)));131 return self::FAILURE;132 }133}134
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.